diff --git a/back/src/main/java/com/back/BackApplication.java b/back/src/main/java/com/back/BackApplication.java index 5c84eef1..89fd1b4e 100644 --- a/back/src/main/java/com/back/BackApplication.java +++ b/back/src/main/java/com/back/BackApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class BackApplication { public static void main(String[] args) { diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapIntegrationQueue.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapIntegrationQueue.java new file mode 100644 index 00000000..3ffb88b2 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapIntegrationQueue.java @@ -0,0 +1,55 @@ +package com.back.domain.roadmap.roadmap.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name= "job_roadmap_integration_queue") +@NoArgsConstructor +public class JobRoadmapIntegrationQueue { + @Id + @Column(name = "job_id") + private Long jobId; + + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + @Column(name = "retry_count", nullable = false) + private Integer retryCount = 0; + + @Version + @Column(name = "version") + private Long version = 0L; + + public JobRoadmapIntegrationQueue(Long jobId) { + this.jobId = jobId; + this.requestedAt = LocalDateTime.now(); + this.retryCount = 0; + } + + public void updateRequestedAt() { + this.requestedAt = LocalDateTime.now(); + } + + public void incrementRetryCount() { + this.retryCount += 1; + } + + public boolean isMaxRetryExceeded(int maxRetry) { + return this.retryCount >= maxRetry; + } + + public Long getJobId() { + return jobId; + } + + public Integer getRetryCount() { + return retryCount; + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java index 99b2198d..5d8cb126 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/entity/JobRoadmapNodeStat.java @@ -47,53 +47,29 @@ public class JobRoadmapNodeStat extends BaseEntity { private String alternativeParents; // 대안 부모 후보들: JSON 형태 { "T:1": 8, "N:kotlin": 7 } @Builder - public JobRoadmapNodeStat(Integer stepOrder, Double weight, RoadmapNode node) { - this.stepOrder = stepOrder; - this.weight = weight != null ? weight : 0.0; + public JobRoadmapNodeStat( + RoadmapNode node, + Integer stepOrder, + Double weight, + Double averagePosition, + Integer mentorCount, + Integer totalMentorCount, + Double mentorCoverageRatio, + Integer outgoingTransitions, + Integer incomingTransitions, + String transitionCounts, + String alternativeParents + ) { this.node = node; - } - - public void setStepOrder(Integer stepOrder) { this.stepOrder = stepOrder; - } - - public void setWeight(Double weight) { - this.weight = weight; - } - - public void setNode(RoadmapNode node) { - this.node = node; - } - - public void setAveragePosition(Double averagePosition) { + this.weight = weight != null ? weight : 0.0; this.averagePosition = averagePosition; - } - - public void setMentorCount(Integer mentorCount) { this.mentorCount = mentorCount; - } - - public void setTotalMentorCount(Integer totalMentorCount) { this.totalMentorCount = totalMentorCount; - } - - public void setMentorCoverageRatio(Double mentorCoverageRatio) { this.mentorCoverageRatio = mentorCoverageRatio; - } - - public void setOutgoingTransitions(Integer outgoingTransitions) { this.outgoingTransitions = outgoingTransitions; - } - - public void setIncomingTransitions(Integer incomingTransitions) { this.incomingTransitions = incomingTransitions; - } - - public void setTransitionCounts(String transitionCounts) { this.transitionCounts = transitionCounts; - } - - public void setAlternativeParents(String alternativeParents) { this.alternativeParents = alternativeParents; } } 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 d2beac97..80956cd5 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 @@ -97,6 +97,12 @@ public RoadmapNode(String taskName, String learningAdvice, String recommendedRes } + // ========== 도메인 메서드 (Public) ========== + + /** + * 자식 노드를 추가하고 부모-자식 관계를 설정합니다. + * 자식의 level은 부모 level + 1로 자동 설정됩니다. + */ public void addChild(RoadmapNode child) { if (child == null) { throw new IllegalArgumentException("자식 노드는 null일 수 없습니다."); @@ -106,34 +112,54 @@ public void addChild(RoadmapNode child) { } this.children.add(child); child.setParent(this); - child.setLevel(this.level + 1); // 부모 level + 1로 자동 설정 + child.setLevel(this.level + 1); } - public void setParent(RoadmapNode parent) { - this.parent = parent; + /** + * 이 노드를 특정 로드맵에 할당합니다. + * JobRoadmap 또는 MentorRoadmap 저장 후 ID를 받은 시점에 호출됩니다. + * + * @param roadmapId 로드맵 ID + * @param roadmapType 로드맵 타입 (JOB 또는 MENTOR) + */ + public void assignToRoadmap(Long roadmapId, RoadmapType roadmapType) { + if (roadmapId == null) { + throw new IllegalArgumentException("roadmapId는 null일 수 없습니다."); + } + if (roadmapType == null) { + throw new IllegalArgumentException("roadmapType은 null일 수 없습니다."); + } + this.roadmapId = roadmapId; + this.roadmapType = roadmapType; } - public void setLevel(int level) { - this.level = level; + /** + * 이 노드를 루트 노드로 초기화합니다. + * level=0, stepOrder=1로 설정됩니다. + * 직업 로드맵 통합 알고리즘에서 메인 루트 설정 시 사용됩니다. + */ + public void initializeAsRoot() { + this.level = 0; + this.stepOrder = 1; } - public void setStepOrder(int stepOrder) { - this.stepOrder = stepOrder; + /** + * 형제 노드들 사이에서의 순서를 할당합니다. + * BFS 트리 구성 시 부모의 자식들 중 몇 번째인지 설정하는 데 사용됩니다. + * + * @param order 형제 노드 중 순서 (1부터 시작) + */ + public void assignOrderInSiblings(int order) { + this.stepOrder = order; } - public void setRoadmapId(Long roadmapId) { - this.roadmapId = roadmapId; - } - - public void setRoadmapType(RoadmapType roadmapType) { - this.roadmapType = roadmapType; - } + // ========== Package-private 메서드 (같은 패키지에서만 접근) ========== - public void setTask(Task task) { - this.task = task; + void setParent(RoadmapNode parent) { + this.parent = parent; } - public void setTaskName(String taskName) { - this.taskName = taskName; + void setLevel(int level) { + this.level = level; } } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/event/MentorRoadmapChangeEvent.java b/back/src/main/java/com/back/domain/roadmap/roadmap/event/MentorRoadmapChangeEvent.java new file mode 100644 index 00000000..c25b8bf3 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/event/MentorRoadmapChangeEvent.java @@ -0,0 +1,13 @@ +package com.back.domain.roadmap.roadmap.event; + +public class MentorRoadmapChangeEvent { + private final Long jobId; + + public MentorRoadmapChangeEvent(Long jobId) { + this.jobId = jobId; + } + + public Long getJobId() { + return jobId; + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapIntegrationQueueRepository.java b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapIntegrationQueueRepository.java new file mode 100644 index 00000000..27dd0ddd --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapIntegrationQueueRepository.java @@ -0,0 +1,12 @@ +package com.back.domain.roadmap.roadmap.repository; + +import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface JobRoadmapIntegrationQueueRepository extends JpaRepository { + @Query("SELECT q FROM JobRoadmapIntegrationQueue q ORDER BY q.requestedAt ASC") + List findAllOrderByRequestedAt(); +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java index 62b67616..8beb2301 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/repository/JobRoadmapNodeStatRepository.java @@ -2,6 +2,7 @@ import com.back.domain.roadmap.roadmap.entity.JobRoadmapNodeStat; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -15,4 +16,8 @@ public interface JobRoadmapNodeStatRepository extends JpaRepository findByNode_RoadmapIdWithNode(@Param("roadmapId") Long roadmapId); + + @Modifying + @Query("DELETE FROM JobRoadmapNodeStat s WHERE s.node.id IN :nodeIds") + void deleteByNodeIdIn(@Param("nodeIds") List nodeIds); } 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 index fc67d543..610757f0 100644 --- 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 @@ -20,6 +20,36 @@ void deleteByRoadmapIdAndRoadmapType( @Param("roadmapType") RoadmapNode.RoadmapType roadmapType ); + @Query("SELECT MAX(n.level) FROM RoadmapNode n " + + "WHERE n.roadmapId = :roadmapId AND n.roadmapType = :roadmapType") + Integer findMaxLevelByRoadmapIdAndRoadmapType( + @Param("roadmapId") Long roadmapId, + @Param("roadmapType") RoadmapNode.RoadmapType roadmapType); + + @Modifying + @Query("DELETE FROM RoadmapNode n " + + "WHERE n.roadmapId = :roadmapId " + + "AND n.roadmapType = :roadmapType " + + "AND n.level = :level") + void deleteByRoadmapIdAndRoadmapTypeAndLevel( + @Param("roadmapId") Long roadmapId, + @Param("roadmapType") RoadmapNode.RoadmapType roadmapType, + @Param("level") int level); + + // 부모-자식 구조를 가진 엔티티를 삭제하기 위해 자식부터 순서대로 삭제(PQL 2단계 방식) + @Modifying + @Query("DELETE FROM RoadmapNode r WHERE r.parent IS NOT NULL AND r.roadmapId = :roadmapId AND r.roadmapType = :roadmapType") + void deleteChildren(@Param("roadmapId") Long roadmapId, @Param("roadmapType") RoadmapNode.RoadmapType roadmapType); + + @Modifying + @Query("DELETE FROM RoadmapNode r WHERE r.parent IS NULL AND r.roadmapId = :roadmapId AND r.roadmapType = :roadmapType") + void deleteParents(@Param("roadmapId") Long roadmapId, @Param("roadmapType") RoadmapNode.RoadmapType roadmapType); + + + @Query("SELECT n.id FROM RoadmapNode n WHERE n.roadmapId = :roadmapId AND n.roadmapType = :roadmapType") + List findIdsByRoadmapIdAndRoadmapType(@Param("roadmapId") Long roadmapId, + @Param("roadmapType") RoadmapNode.RoadmapType roadmapType); + // 조회용 메서드 (성능 최적화용) List findByRoadmapIdAndRoadmapTypeOrderByStepOrder( Long roadmapId, diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapBatchIntegrator.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapBatchIntegrator.java new file mode 100644 index 00000000..985aa7c2 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapBatchIntegrator.java @@ -0,0 +1,64 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapIntegrationQueueRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JobRoadmapBatchIntegrator { + private final JobRoadmapIntegrationQueueRepository queueRepository; + private final JobRoadmapIntegrationProcessor processor; + private static final int MAX_RETRY = 3; + + @Scheduled(fixedDelay = 120000) // 2분 + public void integrate() { + List pendingQueues = queueRepository.findAllOrderByRequestedAt(); + + if(pendingQueues.isEmpty()) { + log.debug("처리할 큐가 없습니다."); + return; + } + + log.info("직업 로드맵 배치 통합 시작: {}개 직업", pendingQueues.size()); + + int successCount = 0; + int conflictCount = 0; + + for(JobRoadmapIntegrationQueue queue : pendingQueues) { + try { + processor.processQueue(queue); + successCount++; + + } catch (ObjectOptimisticLockingFailureException e) { + // 낙관적 락 충돌: 다른 트랜잭션이 큐를 수정함 (정상 동작) + conflictCount++; + log.info("버전 충돌 발생 (정상): jobId={}, 다음 주기에 재처리", + queue.getJobId()); + + } catch (Exception e) { + // 실제 에러: 통합 로직 실패 등 + log.error("직업 로드맵 통합 실패: jobId={}, error={}", + queue.getJobId(), e.getMessage()); + + try { + processor.handleRetry(queue, MAX_RETRY); + } catch (Exception retryError) { + log.error("재시도 처리 실패: jobId={}, error={}", + queue.getJobId(), retryError.getMessage()); + } + } + } + + int failureCount = pendingQueues.size() - successCount - conflictCount; + log.info("직업 로드맵 배치 통합 완료: 성공 {}, 충돌 {}, 실패 {}, 총 {}개", + successCount, conflictCount, failureCount, pendingQueues.size()); + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationProcessor.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationProcessor.java new file mode 100644 index 00000000..0ebe2497 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationProcessor.java @@ -0,0 +1,56 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapIntegrationQueueRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class JobRoadmapIntegrationProcessor { + private final JobRoadmapIntegrationQueueRepository queueRepository; + private final JobRoadmapIntegrationServiceV2 integrationService; + + /** + * 단일 큐 항목 처리 (통합 + 큐 삭제를 하나의 트랜잭션으로) + * REQUIRES_NEW: 각 큐 항목이 독립적인 트랜잭션 + * + * ObjectOptimisticLockingFailureException 발생 시: + * - 다른 트랜잭션(이벤트 리스너)이 이 큐를 동시에 수정함 + * - 최신 요청(requestedAt 갱신)이 반영되었으므로 이번 처리는 무시 + * - 전체 트랜잭션 롤백 (통합 결과도 저장 안 됨) + * - 다음 스케줄링 때 갱신된 큐로 재처리됨 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void processQueue(JobRoadmapIntegrationQueue queue) { + Long jobId = queue.getJobId(); + + // 1. 통합 실행 + integrationService.integrateJobRoadmap(jobId); + + // 2. 성공 시 큐 삭제 (같은 트랜잭션) + queueRepository.delete(queue); + + log.info("직업 로드맵 통합 성공: jobId={}", jobId); + } + + /** + * 재시도 로직 처리 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleRetry(JobRoadmapIntegrationQueue queue, int maxRetry) { + if (queue.isMaxRetryExceeded(maxRetry)) { + queueRepository.delete(queue); + log.warn("최대 재시도 횟수 초과로 큐에서 제거: jobId={}", queue.getJobId()); + } else { + queue.incrementRetryCount(); + queue.updateRequestedAt(); + queueRepository.save(queue); + log.info("재시도 예약: jobId={}, retryCount={}", queue.getJobId(), queue.getRetryCount()); + } + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationService.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationService.java index b0c06704..cb98a8d8 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationService.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationService.java @@ -351,7 +351,7 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { // 연결 수행 visited.add(ck); parentNode.addChild(childNode); - childNode.setStepOrder(order++); + childNode.assignOrderInSiblings(order++); q.add(ck); } } @@ -372,7 +372,7 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { visited.add(dc.childKey); bestParentNode.addChild(childNode); int childCount = bestParentNode.getChildren().size(); - childNode.setStepOrder(childCount); + childNode.assignOrderInSiblings(childCount); deferredProcessed++; log.debug("Deferred 연결 성공: {} -> {}", bestParent, dc.childKey); } else { @@ -393,7 +393,7 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { visited.add(dc.childKey); fallbackParentNode.addChild(childNode); int childCount = fallbackParentNode.getChildren().size(); - childNode.setStepOrder(childCount); + childNode.assignOrderInSiblings(childCount); log.debug("Fallback 연결: {} -> {}", dc.parentKey, dc.childKey); } else { // 최종 실패 -> 대안 기록 @@ -408,8 +408,7 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { // 메인 루트 설정 (단일 루트만 허용) RoadmapNode mainRoot = keyToNode.get(rootKey); if (mainRoot != null) { - mainRoot.setStepOrder(1); - mainRoot.setLevel(0); + mainRoot.initializeAsRoot(); } // 고아 노드(visited되지 않은 노드) 로그 기록 @@ -447,8 +446,7 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { // 모든 노드에 roadmapId, roadmapType 설정 후 JobRoadmap의 노드로 추가 for (RoadmapNode n : allNodes) { - n.setRoadmapId(roadmapId); - n.setRoadmapType(RoadmapType.JOB); + n.assignToRoadmap(roadmapId, RoadmapType.JOB); jobRoadmap.getNodes().add(n); } @@ -459,18 +457,13 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { for (RoadmapNode persisted : saved.getNodes()) { String k = generateKey(persisted); AggregatedNode a = agg.get(k); - JobRoadmapNodeStat stat = new JobRoadmapNodeStat(); - stat.setNode(persisted); - stat.setStepOrder(persisted.getStepOrder()); // 해당 키를 선택한 멘토 수 int mentorCount = mentorAppearSet.getOrDefault(k, Collections.emptySet()).size(); - stat.setMentorCount(mentorCount); // 평균 단계 위치 List posList = positions.getOrDefault(k, Collections.emptyList()); Double avgPos = posList.isEmpty() ? null : posList.stream().mapToInt(Integer::intValue).average().orElse(0.0); - stat.setAveragePosition(avgPos); // Weight 계산: priorityScore와 동일한 복합 가중치 사용 // (등장빈도, 멘토커버리지, 평균위치, 연결성) @@ -494,30 +487,34 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { // 방어적 코딩: 0~1 범위로 클램프 weight = Math.max(0.0, Math.min(1.0, weight)); - stat.setWeight(weight); - stat.setTotalMentorCount(totalMentorCount); - stat.setMentorCoverageRatio(mentorCoverageScore); // 0.0 ~ 1.0 - stat.setOutgoingTransitions(outgoing); - stat.setIncomingTransitions(incoming); - // 다음으로 이어지는 노드들의 분포 (Ut.json 사용) Map outMap = transitions.getOrDefault(k, Collections.emptyMap()); + String transitionCountsJson = null; if (!outMap.isEmpty()) { - String json = Ut.json.toString(outMap); - if (json != null) { - stat.setTransitionCounts(json); - } + transitionCountsJson = Ut.json.toString(outMap); } // 대안 부모 정보 저장 (메타정보 포함, Ut.json 사용) List altParents = skippedParents.get(k); + String alternativeParentsJson = null; if (altParents != null && !altParents.isEmpty()) { - String json = Ut.json.toString(altParents); - if (json != null) { - stat.setAlternativeParents(json); - } + alternativeParentsJson = Ut.json.toString(altParents); } + JobRoadmapNodeStat stat = JobRoadmapNodeStat.builder() + .node(persisted) + .stepOrder(persisted.getStepOrder()) + .weight(weight) + .averagePosition(avgPos) + .mentorCount(mentorCount) + .totalMentorCount(totalMentorCount) + .mentorCoverageRatio(mentorCoverageScore) + .outgoingTransitions(outgoing) + .incomingTransitions(incoming) + .transitionCounts(transitionCountsJson) + .alternativeParents(alternativeParentsJson) + .build(); + stats.add(stat); } diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationServiceV2.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationServiceV2.java new file mode 100644 index 00000000..184f2710 --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationServiceV2.java @@ -0,0 +1,803 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.job.job.entity.Job; +import com.back.domain.job.job.repository.JobRepository; +import com.back.domain.roadmap.roadmap.entity.JobRoadmap; +import com.back.domain.roadmap.roadmap.entity.JobRoadmapNodeStat; +import com.back.domain.roadmap.roadmap.entity.MentorRoadmap; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode.RoadmapType; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapNodeStatRepository; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapRepository; +import com.back.domain.roadmap.roadmap.repository.MentorRoadmapRepository; +import com.back.domain.roadmap.task.entity.Task; +import com.back.domain.roadmap.task.repository.TaskRepository; +import com.back.global.exception.ServiceException; +import com.back.standard.util.Ut; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class JobRoadmapIntegrationServiceV2 { + private final MentorRoadmapRepository mentorRoadmapRepository; + private final JobRepository jobRepository; + private final JobRoadmapRepository jobRoadmapRepository; + private final TaskRepository taskRepository; + private final JobRoadmapNodeStatRepository jobRoadmapNodeStatRepository; + private final com.back.domain.roadmap.roadmap.repository.RoadmapNodeRepository roadmapNodeRepository; + + // --- 통합 알고리즘 상수 --- + private final double BRANCH_THRESHOLD = 0.25; + private final int MAX_DEPTH = 10; + private final int MAX_CHILDREN = 4; + private final int MAX_DEFERRED_RETRY = 3; + + // --- 품질 필터링 상수 --- + private static final double MIN_STANDARDIZATION_RATE = 0.5; // 최소 표준화율 50% + private static final double MIN_QUALITY_THRESHOLD = 0.4; // 최소 품질 점수 40% + private static final double QUALITY_NODE_COUNT_WEIGHT = 0.3; // 품질 점수: 노드 개수 가중치 + private static final double QUALITY_STANDARDIZATION_WEIGHT = 0.7; // 품질 점수: 표준화율 가중치 + + // --- 부모 우선순위 점수(priorityScore) 가중치 --- + private static final double W_TRANSITION_POPULARITY = 0.4; // 전체 멘토 대비 전이 빈도 (전체적인 인기) + private static final double W_TRANSITION_STRENGTH = 0.3; // 부모 노드 내에서의 전이 강도 (연결의 확실성) + private static final double W_POSITION_SIMILARITY = 0.2; // 부모-자식 노드 간 평균 위치 유사성 + private static final double W_MENTOR_COVERAGE = 0.1; // 부모 노드의 신뢰도 (얼마나 많은 멘토가 언급했나) + + + // ======================================== + // Public API + // ======================================== + + @Transactional + public JobRoadmap integrateJobRoadmap(Long jobId) { + // 1. 데이터 준비 + Job job = validateAndGetJob(jobId); + deleteExistingJobRoadmap(job); + List mentorRoadmaps = loadAndFilterMentorRoadmaps(jobId); + + // 2. 통계 집계 + AggregationResult aggregation = aggregateStatistics(mentorRoadmaps); + + // 3. 트리 구성 + TreeBuildResult treeResult = buildMainTree(aggregation); + + // 4. 로그 및 영속화 + logOrphanNodes(treeResult, aggregation); + return persistJobRoadmap(job, treeResult, aggregation); + } + + + // ======================================== + // Step 1: 데이터 준비 및 필터링 + // ======================================== + + private Job validateAndGetJob(Long jobId) { + return jobRepository.findById(jobId) + .orElseThrow(() -> new ServiceException("404", "직업을 찾을 수 없습니다. id=" + jobId)); + } + + private void deleteExistingJobRoadmap(Job job) { + jobRoadmapRepository.findByJob(job).ifPresent(existing -> { + Long roadmapId = existing.getId(); + + // 1. 연관된 노드 ID들 조회 + List nodeIds = roadmapNodeRepository.findIdsByRoadmapIdAndRoadmapType( + roadmapId, + RoadmapType.JOB + ); + + // 2. 해당 노드들의 JobRoadmapNodeStat 먼저 삭제 + if (!nodeIds.isEmpty()) { + jobRoadmapNodeStatRepository.deleteByNodeIdIn(nodeIds); + } + + // 3. 레벨이 깊은 노드부터 삭제 (leaf → root 순서) + // 최대 레벨 조회 + Integer maxLevel = roadmapNodeRepository.findMaxLevelByRoadmapIdAndRoadmapType( + roadmapId, RoadmapType.JOB); + + if (maxLevel != null) { + // 가장 깊은 레벨부터 0까지 역순으로 삭제 + for (int level = maxLevel; level >= 0; level--) { + roadmapNodeRepository.deleteByRoadmapIdAndRoadmapTypeAndLevel( + roadmapId, RoadmapType.JOB, level); + } + } + + // 5. JobRoadmap 삭제 + jobRoadmapRepository.delete(existing); + + log.info("기존 JobRoadmap 삭제: id={}, 노드들 먼저 삭제됨", roadmapId); + }); + } + + private List loadAndFilterMentorRoadmaps(Long jobId) { + List all = mentorRoadmapRepository.findAllByMentorJobIdWithNodes(jobId); + + List filtered = all.stream() + .filter(mr -> mr.getNodes() != null && mr.getNodes().size() >= 3) + .filter(mr -> calculateStandardizationRate(mr) >= MIN_STANDARDIZATION_RATE) + .filter(mr -> calculateRoadmapQuality(mr) >= MIN_QUALITY_THRESHOLD) + .toList(); + + if (filtered.isEmpty()) { + throw new ServiceException("404", "해당 직업에 대한 유효한 멘토 로드맵이 존재하지 않습니다. " + + "(최소 조건: 노드 3개 이상, 표준화율 " + (int)(MIN_STANDARDIZATION_RATE * 100) + "% 이상, 품질 점수 " + MIN_QUALITY_THRESHOLD + " 이상)"); + } + + log.info("멘토 로드맵 품질 필터링: 전체 {}개 → 유효 {}개 (노드 3개 이상, 표준화율 {}% 이상, 품질 점수 {} 이상)", + all.size(), filtered.size(), (int)(MIN_STANDARDIZATION_RATE * 100), MIN_QUALITY_THRESHOLD); + + return filtered; + } + + + // ======================================== + // Step 2: 통계 집계 + // ======================================== + + private AggregationResult aggregateStatistics(List mentorRoadmaps) { + AggregationResult result = new AggregationResult(mentorRoadmaps.size()); + + for (MentorRoadmap mr : mentorRoadmaps) { + List nodes = getSortedNodes(mr); + if (nodes.isEmpty()) continue; + + aggregateRootCandidate(nodes.get(0), result); + aggregateNodesFromRoadmap(mr, nodes, result); + } + + return result; + } + + private List getSortedNodes(MentorRoadmap mr) { + return mr.getNodes().stream() + .sorted(Comparator.comparingInt(RoadmapNode::getStepOrder)) + .toList(); + } + + private void aggregateRootCandidate(RoadmapNode first, AggregationResult result) { + result.rootCount.merge(generateKey(first), 1, Integer::sum); + } + + private void aggregateNodesFromRoadmap(MentorRoadmap mr, List nodes, AggregationResult result) { + Long mentorId = mr.getMentor().getId(); + + for (int i = 0; i < nodes.size(); i++) { + RoadmapNode rn = nodes.get(i); + String key = generateKey(rn); + + aggregateNodeStatistics(rn, key, i + 1, mentorId, result); + aggregateDescriptions(rn, key, result.descriptions); + + if (i < nodes.size() - 1) { + aggregateTransition(key, generateKey(nodes.get(i + 1)), result); + } + } + } + + private void aggregateNodeStatistics(RoadmapNode rn, String key, int position, Long mentorId, AggregationResult result) { + result.agg.computeIfAbsent(key, kk -> new AggregatedNode( + rn.getTask(), + rn.getTask() != null ? rn.getTask().getName() : rn.getTaskName() + )).count++; + + result.positions.computeIfAbsent(key, kk -> new ArrayList<>()).add(position); + result.mentorAppearSet.computeIfAbsent(key, kk -> new HashSet<>()).add(mentorId); + } + + private void aggregateDescriptions(RoadmapNode rn, String key, DescriptionCollections descriptions) { + if (rn.getLearningAdvice() != null && !rn.getLearningAdvice().isBlank()) { + descriptions.learningAdvices.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getLearningAdvice()); + } + + if (rn.getRecommendedResources() != null && !rn.getRecommendedResources().isBlank()) { + descriptions.recommendedResources.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getRecommendedResources()); + } + + if (rn.getLearningGoals() != null && !rn.getLearningGoals().isBlank()) { + descriptions.learningGoals.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getLearningGoals()); + } + + if (rn.getDifficulty() != null) { + descriptions.difficulties.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getDifficulty()); + } + + if (rn.getImportance() != null) { + descriptions.importances.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getImportance()); + } + + if (rn.getEstimatedHours() != null) { + descriptions.estimatedHours.computeIfAbsent(key, kk -> new ArrayList<>()).add(rn.getEstimatedHours()); + } + } + + private void aggregateTransition(String fromKey, String toKey, AggregationResult result) { + result.transitions.computeIfAbsent(fromKey, kk -> new HashMap<>()).merge(toKey, 1, Integer::sum); + } + + + // ======================================== + // Step 3: 트리 구성 + // ======================================== + + private TreeBuildResult buildMainTree(AggregationResult aggregation) { + String rootKey = selectRootKey(aggregation); + Map taskMap = prefetchTasks(aggregation); + Map keyToNode = createNodes(aggregation, taskMap); + ParentEvaluation parentEval = evaluateParentCandidates(aggregation, keyToNode); + + return constructTreeViaBFS(rootKey, keyToNode, parentEval, aggregation); + } + + private String selectRootKey(AggregationResult aggregation) { + String rootKey = aggregation.rootCount.entrySet().stream() + .max(Comparator.comparingInt((Map.Entry e) -> e.getValue()) + .thenComparing(Map.Entry::getKey)) + .map(Map.Entry::getKey) + .orElseGet(() -> aggregation.agg.entrySet().stream() + .max(Comparator.comparingInt(e -> e.getValue().count)) + .map(Map.Entry::getKey) + .orElseThrow()); + + log.info("선택된 rootKey={} (빈도={})", rootKey, aggregation.rootCount.getOrDefault(rootKey, 0)); + return rootKey; + } + + /** + * Task 엔티티를 일괄 로드하여 N+1 문제 방지 + * @param aggregation 집계 결과 + * @return taskId → Task 매핑 + */ + private Map prefetchTasks(AggregationResult aggregation) { + Set taskIds = aggregation.agg.values().stream() + .map(a -> a.task != null ? a.task.getId() : null) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Map taskMap = new HashMap<>(); + if (!taskIds.isEmpty()) { + taskRepository.findAllById(taskIds).forEach(t -> taskMap.put(t.getId(), t)); + } + + return taskMap; + } + + private Map createNodes(AggregationResult aggregation, Map taskMap) { + Map keyToNode = new HashMap<>(); + + aggregation.agg.forEach((key, aggNode) -> { + Double avgDifficulty = calculateAverage(aggregation.descriptions.difficulties.get(key)); + Double avgImportance = calculateAverage(aggregation.descriptions.importances.get(key)); + Integer avgEstimatedHours = calculateIntegerAverage(aggregation.descriptions.estimatedHours.get(key)); + + RoadmapNode node = RoadmapNode.builder() + .taskName(aggNode.displayName) + .learningAdvice(mergeTopDescriptions(aggregation.descriptions.learningAdvices.get(key))) + .recommendedResources(mergeTopDescriptions(aggregation.descriptions.recommendedResources.get(key))) + .learningGoals(mergeTopDescriptions(aggregation.descriptions.learningGoals.get(key))) + .difficulty(avgDifficulty != null ? avgDifficulty.intValue() : null) + .importance(avgImportance != null ? avgImportance.intValue() : null) + .estimatedHours(avgEstimatedHours) + .task(aggNode.task != null ? taskMap.get(aggNode.task.getId()) : null) + .roadmapId(0L) + .roadmapType(RoadmapType.JOB) + .build(); + + keyToNode.put(key, node); + }); + + return keyToNode; + } + + private ParentEvaluation evaluateParentCandidates(AggregationResult aggregation, Map keyToNode) { + Map> chosenChildren = new HashMap<>(); + Map> childToParentCandidates = new HashMap<>(); + + for (Map.Entry> e : aggregation.transitions.entrySet()) { + String parentKey = e.getKey(); + Map childTransitions = e.getValue(); + int parentTotalTransitions = childTransitions.values().stream().mapToInt(Integer::intValue).sum(); + + List> sortedChildren = childTransitions.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(MAX_CHILDREN) + .toList(); + + List chosen = new ArrayList<>(); + for (int i = 0; i < sortedChildren.size(); i++) { + Map.Entry ce = sortedChildren.get(i); + String childKey = ce.getKey(); + int transitionCount = ce.getValue(); + + if (i == 0) { + chosen.add(childKey); + } else { + double ratio = (double) transitionCount / aggregation.agg.get(parentKey).count; + if (ratio >= BRANCH_THRESHOLD) { + chosen.add(childKey); + } + } + + double priorityScore = calculatePriorityScore(parentKey, childKey, transitionCount, + parentTotalTransitions, aggregation); + + childToParentCandidates.computeIfAbsent(childKey, k -> new ArrayList<>()) + .add(new ParentCandidate(parentKey, transitionCount, priorityScore)); + } + + if (!chosen.isEmpty()) { + chosenChildren.put(parentKey, chosen); + } + } + + Map childToBestParent = new HashMap<>(); + childToParentCandidates.forEach((child, candidates) -> { + candidates.sort(Comparator.comparingDouble(ParentCandidate::getPriorityScore).reversed() + .thenComparing(ParentCandidate::getParentKey)); + childToBestParent.put(child, candidates.get(0).parentKey); + }); + + return new ParentEvaluation(chosenChildren, childToBestParent); + } + + private double calculatePriorityScore(String parentKey, String childKey, int transitionCount, + int parentTotalTransitions, AggregationResult aggregation) { + double transitionPopularity = (double) transitionCount / aggregation.totalMentorCount; + double transitionStrength = (double) transitionCount / parentTotalTransitions; + + double avgParentPos = aggregation.positions.getOrDefault(parentKey, Collections.emptyList()) + .stream().mapToInt(Integer::intValue).average().orElse(99.0); + double avgChildPos = aggregation.positions.getOrDefault(childKey, Collections.emptyList()) + .stream().mapToInt(Integer::intValue).average().orElse(99.0); + double positionSimilarity = 1.0 / (1.0 + Math.abs(avgParentPos - avgChildPos)); + + double mentorCoverage = (double) aggregation.mentorAppearSet.getOrDefault(parentKey, Collections.emptySet()).size() + / aggregation.totalMentorCount; + + return W_TRANSITION_POPULARITY * transitionPopularity + + W_TRANSITION_STRENGTH * transitionStrength + + W_POSITION_SIMILARITY * positionSimilarity + + W_MENTOR_COVERAGE * mentorCoverage; + } + + private TreeBuildResult constructTreeViaBFS(String rootKey, Map keyToNode, + ParentEvaluation parentEval, AggregationResult aggregation) { + TreeBuildResult result = new TreeBuildResult(rootKey, keyToNode); + Queue deferredQueue = new ArrayDeque<>(); + + performFirstPassBFS(result, parentEval, aggregation, deferredQueue); + performDeferredRetry(result, parentEval, deferredQueue); + initializeMainRoot(result); + + return result; + } + + private void performFirstPassBFS(TreeBuildResult result, ParentEvaluation parentEval, + AggregationResult aggregation, Queue deferredQueue) { + Queue q = new ArrayDeque<>(); + q.add(result.rootKey); + + while (!q.isEmpty()) { + String pk = q.poll(); + RoadmapNode parentNode = result.keyToNode.get(pk); + List childs = parentEval.chosenChildren.getOrDefault(pk, Collections.emptyList()); + int order = 1; + + for (String ck : childs) { + if (result.visited.contains(ck)) { + recordAsAlternative(pk, ck, aggregation, result); + continue; + } + + RoadmapNode childNode = result.keyToNode.get(ck); + if (childNode == null) continue; + + String bestParent = parentEval.childToBestParent.get(ck); + if (bestParent != null && !bestParent.equals(pk)) { + if (!result.visited.contains(bestParent)) { + deferredQueue.add(new DeferredChild(pk, ck, MAX_DEFERRED_RETRY)); + } else { + recordAsAlternative(pk, ck, aggregation, result); + } + continue; + } + + if (parentNode.getLevel() + 1 >= MAX_DEPTH) { + log.warn("MAX_DEPTH({}) 초과로 노드 추가 중단: parent={}, child={}", MAX_DEPTH, pk, ck); + recordAsAlternative(pk, ck, aggregation, result); + continue; + } + + result.visited.add(ck); + parentNode.addChild(childNode); + childNode.assignOrderInSiblings(order++); + q.add(ck); + } + } + } + + private void performDeferredRetry(TreeBuildResult result, ParentEvaluation parentEval, + Queue deferredQueue) { + int deferredProcessed = 0; + + while (!deferredQueue.isEmpty()) { + DeferredChild dc = deferredQueue.poll(); + if (result.visited.contains(dc.childKey)) continue; + + String bestParent = parentEval.childToBestParent.get(dc.childKey); + if (bestParent != null && result.visited.contains(bestParent)) { + RoadmapNode bestParentNode = result.keyToNode.get(bestParent); + RoadmapNode childNode = result.keyToNode.get(dc.childKey); + + if (bestParentNode != null && childNode != null && bestParentNode.getLevel() + 1 < MAX_DEPTH) { + result.visited.add(dc.childKey); + bestParentNode.addChild(childNode); + childNode.assignOrderInSiblings(bestParentNode.getChildren().size()); + deferredProcessed++; + } + } else if (dc.retryCount > 0) { + deferredQueue.add(new DeferredChild(dc.parentKey, dc.childKey, dc.retryCount - 1)); + } + } + + log.info("Deferred 재시도 완료: {}개 노드 연결", deferredProcessed); + } + + private void initializeMainRoot(TreeBuildResult result) { + RoadmapNode mainRoot = result.keyToNode.get(result.rootKey); + if (mainRoot != null) { + mainRoot.initializeAsRoot(); + } + } + + private void recordAsAlternative(String parentKey, String childKey, AggregationResult aggregation, TreeBuildResult result) { + int transitionCount = aggregation.transitions.getOrDefault(parentKey, Collections.emptyMap()) + .getOrDefault(childKey, 0); + int parentMentorCount = aggregation.mentorAppearSet.getOrDefault(parentKey, Collections.emptySet()).size(); + + double transitionPopularity = (double) transitionCount / aggregation.totalMentorCount; + + List parentPosList = aggregation.positions.getOrDefault(parentKey, Collections.emptyList()); + double avgParentPos = parentPosList.isEmpty() ? 99.0 + : parentPosList.stream().mapToInt(Integer::intValue).average().orElse(99.0); + double positionScore = 1.0 / (avgParentPos + 1); + + double mentorCoverageScore = (double) parentMentorCount / aggregation.totalMentorCount; + + double score = + W_TRANSITION_POPULARITY * transitionPopularity + + W_POSITION_SIMILARITY * positionScore + + W_MENTOR_COVERAGE * mentorCoverageScore; + + AlternativeParentInfo info = new AlternativeParentInfo(parentKey, transitionCount, parentMentorCount, score); + result.skippedParents.computeIfAbsent(childKey, k -> new ArrayList<>()).add(info); + } + + + // ======================================== + // Step 4: 로그 및 영속화 + // ======================================== + + private void logOrphanNodes(TreeBuildResult result, AggregationResult aggregation) { + long orphanCount = result.keyToNode.values().stream() + .filter(n -> !result.visited.contains(generateKey(n))) + .count(); + + if (orphanCount > 0) { + log.info("=== 제외된 고아 노드 ==="); + result.keyToNode.entrySet().stream() + .filter(e -> !result.visited.contains(e.getKey())) + .forEach(e -> { + String key = e.getKey(); + AggregatedNode aggNode = aggregation.agg.get(key); + int count = aggNode != null ? aggNode.count : 0; + int mentorCount = aggregation.mentorAppearSet.getOrDefault(key, Collections.emptySet()).size(); + log.info(" - 키: {}, 이름: {}, 출현빈도: {}회, 멘토수: {}명", + key, e.getValue().getTaskName(), count, mentorCount); + }); + log.info("총 {}개의 저빈도 노드가 메인 트리에서 제외되었습니다.", orphanCount); + } + } + + private JobRoadmap persistJobRoadmap(Job job, TreeBuildResult treeResult, AggregationResult aggregation) { + JobRoadmap jobRoadmap = jobRoadmapRepository.save(JobRoadmap.builder().job(job).build()); + Long roadmapId = jobRoadmap.getId(); + + attachNodesToRoadmap(jobRoadmap, treeResult, roadmapId); + JobRoadmap saved = jobRoadmapRepository.save(jobRoadmap); + + saveNodeStatistics(saved, treeResult, aggregation); + + log.info("JobRoadmap 생성 완료: id={}, 노드={}개", saved.getId(), saved.getNodes().size()); + return saved; + } + + private void attachNodesToRoadmap(JobRoadmap roadmap, TreeBuildResult treeResult, Long roadmapId) { + List allNodes = treeResult.keyToNode.values().stream() + .filter(n -> treeResult.visited.contains(generateKey(n))) + .peek(n -> n.assignToRoadmap(roadmapId, RoadmapType.JOB)) + .toList(); + + roadmap.getNodes().addAll(allNodes); + } + + private void saveNodeStatistics(JobRoadmap roadmap, TreeBuildResult treeResult, AggregationResult aggregation) { + List stats = roadmap.getNodes().stream() + .map(node -> createNodeStat(node, treeResult, aggregation)) + .toList(); + + jobRoadmapNodeStatRepository.saveAll(stats); + } + + private JobRoadmapNodeStat createNodeStat(RoadmapNode node, TreeBuildResult treeResult, AggregationResult aggregation) { + String key = generateKey(node); + AggregatedNode aggNode = aggregation.agg.get(key); + + int mentorCount = aggregation.mentorAppearSet.getOrDefault(key, Collections.emptySet()).size(); + + List posList = aggregation.positions.getOrDefault(key, Collections.emptyList()); + Double avgPos = posList.isEmpty() ? null : posList.stream().mapToInt(Integer::intValue).average().orElse(0.0); + + double frequencyScore = aggNode == null ? 0.0 : (double) aggNode.count / (double) aggregation.totalMentorCount; + double mentorCoverageScore = (double) mentorCount / (double) aggregation.totalMentorCount; + double positionScore = avgPos != null ? 1.0 / (avgPos + 1) : 0.0; + + int outgoing = aggregation.transitions.getOrDefault(key, Collections.emptyMap()).values().stream() + .mapToInt(Integer::intValue).sum(); + int incoming = aggregation.transitions.entrySet().stream() + .mapToInt(e -> e.getValue().getOrDefault(key, 0)).sum(); + int totalTransitions = outgoing + incoming; + double connectivityScore = totalTransitions > 0 ? Math.min(1.0, (double) totalTransitions / (aggregation.totalMentorCount * 2)) : 0.0; + + double weight = 0.4 * frequencyScore + + 0.3 * mentorCoverageScore + + 0.2 * positionScore + + 0.1 * connectivityScore; + + weight = Math.max(0.0, Math.min(1.0, weight)); + + Map outMap = aggregation.transitions.getOrDefault(key, Collections.emptyMap()); + String transitionCountsJson = null; + if (!outMap.isEmpty()) { + transitionCountsJson = Ut.json.toString(outMap); + } + + List altParents = treeResult.skippedParents.get(key); + String alternativeParentsJson = null; + if (altParents != null && !altParents.isEmpty()) { + alternativeParentsJson = Ut.json.toString(altParents); + } + + return JobRoadmapNodeStat.builder() + .node(node) + .stepOrder(node.getStepOrder()) + .weight(weight) + .averagePosition(avgPos) + .mentorCount(mentorCount) + .totalMentorCount(aggregation.totalMentorCount) + .mentorCoverageRatio(mentorCoverageScore) + .outgoingTransitions(outgoing) + .incomingTransitions(incoming) + .transitionCounts(transitionCountsJson) + .alternativeParents(alternativeParentsJson) + .build(); + } + + + // ======================================== + // 헬퍼 메서드 + // ======================================== + + private String generateKey(RoadmapNode rn) { + if (rn.getTask() != null) { + return "T:" + rn.getTask().getId(); + } + String name = rn.getTaskName(); + if (name == null || name.trim().isEmpty()) return "N:__unknown__"; + return "N:" + name.trim().toLowerCase().replaceAll("\\s+", " "); + } + + private String mergeTopDescriptions(List list) { + if (list == null || list.isEmpty()) return null; + LinkedHashSet set = new LinkedHashSet<>(); + for (String s : list) { + if (s == null) continue; + String c = s.trim(); + if (!c.isEmpty()) set.add(c); + if (set.size() >= 3) break; + } + return String.join("\n\n", set); + } + + private Double calculateAverage(List list) { + if (list == null || list.isEmpty()) return null; + return list.stream().mapToInt(Integer::intValue).average().orElse(0.0); + } + + private Integer calculateIntegerAverage(List list) { + Double avg = calculateAverage(list); + return avg != null ? (int) Math.round(avg) : null; + } + + /** + * 멘토 로드맵의 Task 표준화율 계산 + * @param roadmap 멘토 로드맵 + * @return 표준화율 (0.0 ~ 1.0) + */ + private double calculateStandardizationRate(MentorRoadmap roadmap) { + List nodes = roadmap.getNodes(); + if (nodes == null || nodes.isEmpty()) { + return 0.0; + } + + long standardizedCount = nodes.stream() + .filter(n -> n.getTask() != null) + .count(); + + return (double) standardizedCount / nodes.size(); + } + + /** + * 멘토 로드맵의 품질 점수 계산 + * 품질 점수 = (노드 개수 점수 × 0.3) + (표준화율 × 0.7) + * + * @param roadmap 멘토 로드맵 + * @return 품질 점수 (0.0 ~ 1.0) + */ + private double calculateRoadmapQuality(MentorRoadmap roadmap) { + List nodes = roadmap.getNodes(); + if (nodes == null || nodes.isEmpty()) { + return 0.0; + } + + // 노드 개수 점수 (3개=0.0, 15개=1.0) + int nodeCount = nodes.size(); + double nodeScore = Math.min(1.0, (nodeCount - 3.0) / 12.0); + + // 표준화율 + double standardizationScore = calculateStandardizationRate(roadmap); + + // 복합 점수 (노드 개수 30%, 표준화율 70%) + return QUALITY_NODE_COUNT_WEIGHT * nodeScore + QUALITY_STANDARDIZATION_WEIGHT * standardizationScore; + } + + + // ======================================== + // 중간 객체 (Internal DTOs) + // ======================================== + + /** + * 통계 집계 결과를 담는 객체 + */ + @Getter + private static class AggregationResult { + final Map agg = new HashMap<>(); + final Map> transitions = new HashMap<>(); + final Map rootCount = new HashMap<>(); + final Map> mentorAppearSet = new HashMap<>(); + final Map> positions = new HashMap<>(); + final DescriptionCollections descriptions = new DescriptionCollections(); + final int totalMentorCount; + + AggregationResult(int totalMentorCount) { + this.totalMentorCount = totalMentorCount; + } + } + + /** + * Description 관련 필드들을 묶은 객체 + */ + @Getter + private static class DescriptionCollections { + final Map> learningAdvices = new HashMap<>(); + final Map> recommendedResources = new HashMap<>(); + final Map> learningGoals = new HashMap<>(); + final Map> difficulties = new HashMap<>(); + final Map> importances = new HashMap<>(); + final Map> estimatedHours = new HashMap<>(); + } + + /** + * 트리 구성 결과를 담는 객체 + */ + @Getter + private static class TreeBuildResult { + final String rootKey; + final Map keyToNode; + final Set visited = new HashSet<>(); + final Map> skippedParents = new HashMap<>(); + + TreeBuildResult(String rootKey, Map keyToNode) { + this.rootKey = rootKey; + this.keyToNode = keyToNode; + visited.add(rootKey); + } + } + + /** + * 부모 후보 평가 결과를 담는 객체 + */ + @Getter + private static class ParentEvaluation { + final Map> chosenChildren; + final Map childToBestParent; + + ParentEvaluation(Map> chosenChildren, + Map childToBestParent) { + this.chosenChildren = chosenChildren; + this.childToBestParent = childToBestParent; + } + } + + + // ======================================== + // 헬퍼 클래스 (Helper Classes) + // ======================================== + + private static class AggregatedNode { + Task task; + String displayName; + int count = 0; + + AggregatedNode(Task task, String displayName) { + this.task = task; + this.displayName = displayName; + } + } + + private static class AlternativeParentInfo { + public String parentKey; // 부모 노드 키 (T:1, N:kotlin 등) + public int transitionCount; // 전이 빈도 + public int mentorCount; // 해당 부모를 사용한 멘토 수 + public double score; // 가중치 점수 + + public AlternativeParentInfo(String parentKey, int transitionCount, int mentorCount, double score) { + this.parentKey = parentKey; + this.transitionCount = transitionCount; + this.mentorCount = mentorCount; + this.score = score; + } + } + + private static class ParentCandidate { + private final String parentKey; + private final int transitionCount; + private final double priorityScore; + + public ParentCandidate(String parentKey, int transitionCount, double priorityScore) { + this.parentKey = parentKey; + this.transitionCount = transitionCount; + this.priorityScore = priorityScore; + } + + public String getParentKey() { + return parentKey; + } + + public double getPriorityScore() { + return priorityScore; + } + } + + private static class DeferredChild { + String parentKey; + String childKey; + int retryCount; + + public DeferredChild(String parentKey, String childKey, int retryCount) { + this.parentKey = parentKey; + this.childKey = childKey; + this.retryCount = retryCount; + } + } +} diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapUpdateEventListener.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapUpdateEventListener.java new file mode 100644 index 00000000..8e40874f --- /dev/null +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapUpdateEventListener.java @@ -0,0 +1,39 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.roadmap.roadmap.entity.JobRoadmapIntegrationQueue; +import com.back.domain.roadmap.roadmap.event.MentorRoadmapChangeEvent; +import com.back.domain.roadmap.roadmap.repository.JobRoadmapIntegrationQueueRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JobRoadmapUpdateEventListener { + private final JobRoadmapIntegrationQueueRepository jobRoadmapIntegrationQueueRepository; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void requestJobRoadmapUpdate(MentorRoadmapChangeEvent event) { + Long jobId = event.getJobId(); + + try { + JobRoadmapIntegrationQueue queue = + jobRoadmapIntegrationQueueRepository.findById(jobId) + .orElse(new JobRoadmapIntegrationQueue(jobId)); + + queue.updateRequestedAt(); + jobRoadmapIntegrationQueueRepository.save(queue); + log.info("직업 로드맵 재생성 예약: jobId: {}", jobId); + + } catch (Exception e) { + log.error("큐 저장 실패: jobId={}, error={}", event.getJobId(), e.getMessage()); + // 재발행 또는 별도 에러 큐 저장 + } + } +} 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 11bd1e00..3a0c5e22 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 @@ -8,6 +8,7 @@ import com.back.domain.roadmap.roadmap.dto.response.MentorRoadmapSaveResponse; import com.back.domain.roadmap.roadmap.entity.MentorRoadmap; import com.back.domain.roadmap.roadmap.entity.RoadmapNode; +import com.back.domain.roadmap.roadmap.event.MentorRoadmapChangeEvent; import com.back.domain.roadmap.roadmap.repository.MentorRoadmapRepository; import com.back.domain.roadmap.roadmap.repository.RoadmapNodeRepository; import com.back.domain.roadmap.task.entity.Task; @@ -15,6 +16,7 @@ import com.back.global.exception.ServiceException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +32,7 @@ public class MentorRoadmapService { private final RoadmapNodeRepository roadmapNodeRepository; private final MentorRepository mentorRepository; private final TaskService taskService; + private final ApplicationEventPublisher eventPublisher; // 멘토 로드맵 생성 @Transactional @@ -55,9 +58,10 @@ public MentorRoadmapSaveResponse create(Long mentorId, MentorRoadmapSaveRequest // roadmapId를 포함한 노드 생성 및 추가 List allNodes = createValidatedNodesWithRoadmapId(request.nodes(), mentorRoadmap.getId()); + // CASCADE로 노드들이 자동 저장됨 (추가 save() 호출 불필요) mentorRoadmap.addNodes(allNodes); - // CASCADE로 노드들이 자동 저장됨 (추가 save() 호출 불필요) + eventPublisher.publishEvent(new MentorRoadmapChangeEvent(mentor.getJobId())); log.info("멘토 로드맵 생성 완료 - 멘토 ID: {}, 로드맵 ID: {}, 노드 수: {} (cascade 활용)", mentorId, mentorRoadmap.getId(), mentorRoadmap.getNodes().size()); @@ -130,6 +134,8 @@ public MentorRoadmapSaveResponse update(Long id, Long mentorId, MentorRoadmapSav log.info("멘토 로드맵 수정 완료 - 로드맵 ID: {}, 노드 수: {} (cascade 활용)", mentorRoadmap.getId(), mentorRoadmap.getNodes().size()); + eventPublisher.publishEvent(new MentorRoadmapChangeEvent(mentorRoadmap.getMentor().getJobId())); + return new MentorRoadmapSaveResponse( mentorRoadmap.getId(), mentorRoadmap.getMentor().getId(), @@ -151,6 +157,8 @@ public void delete(Long roadmapId, Long mentorId) { throw new ServiceException("403", "본인의 로드맵만 삭제할 수 있습니다."); } + Long jobId = mentorRoadmap.getMentor().getJobId(); + // 1. 관련 노드들을 먼저 직접 삭제 roadmapNodeRepository.deleteByRoadmapIdAndRoadmapType( roadmapId, @@ -161,6 +169,8 @@ public void delete(Long roadmapId, Long mentorId) { mentorRoadmapRepository.delete(mentorRoadmap); log.info("멘토 로드맵 삭제 완료 - 멘토 ID: {}, 로드맵 ID: {}", mentorId, roadmapId); + + eventPublisher.publishEvent(new MentorRoadmapChangeEvent(jobId)); } // taskId가 null인 자유입력 Task를 자동으로 pending alias로 등록 diff --git a/back/src/main/java/com/back/global/initData/RoadmapInitData.java b/back/src/main/java/com/back/global/initData/RoadmapInitData.java index dae6df93..db43ec38 100644 --- a/back/src/main/java/com/back/global/initData/RoadmapInitData.java +++ b/back/src/main/java/com/back/global/initData/RoadmapInitData.java @@ -14,6 +14,7 @@ import com.back.domain.roadmap.roadmap.repository.JobRoadmapRepository; import com.back.domain.roadmap.roadmap.repository.MentorRoadmapRepository; import com.back.domain.roadmap.roadmap.service.JobRoadmapIntegrationService; +import com.back.domain.roadmap.roadmap.service.JobRoadmapIntegrationServiceV2; import com.back.domain.roadmap.roadmap.service.MentorRoadmapService; import com.back.domain.roadmap.task.entity.Task; import com.back.domain.roadmap.task.repository.TaskRepository; @@ -43,6 +44,7 @@ public class RoadmapInitData { private final MentorRoadmapRepository mentorRoadmapRepository; private final JobRoadmapRepository jobRoadmapRepository; private final JobRoadmapIntegrationService jobRoadmapIntegrationService; + private final JobRoadmapIntegrationServiceV2 jobRoadmapIntegrationServiceV2; @Bean ApplicationRunner baseInitDataApplicationRunner2() { @@ -58,7 +60,8 @@ public void runInitData() { // 통합 로직 테스트 //initTestMentorRoadmaps(); // 테스트용 멘토 로드맵 10개 생성 - //testJobRoadmapIntegration(); // 통합 로직 실행 및 트리 구조 출력 + //testJobRoadmapIntegration(); // V1 통합 로직 실행 및 트리 구조 출력 + //testJobRoadmapIntegrationV2(); // V2 통합 로직 실행 및 트리 구조 출력 } // --- Job 초기화 --- @@ -1052,4 +1055,18 @@ private void printNodeRecursive(RoadmapNode node, String prefix, boolean isLast) printNodeRecursive(children.get(i), childPrefix, isLastChild); } } + + // --- V2 통합 로직 테스트 및 트리 구조 출력 --- + public void testJobRoadmapIntegrationV2() { + Job backendJob = jobRepository.findByName("백엔드 개발자") + .orElseThrow(() -> new RuntimeException("백엔드 개발자 직업을 찾을 수 없습니다.")); + + log.info("\n\n=== V2 직업 로드맵 통합 시작 ==="); + JobRoadmap integratedRoadmap = jobRoadmapIntegrationServiceV2.integrateJobRoadmap(backendJob.getId()); + log.info("=== V2 직업 로드맵 통합 완료 ===\n"); + + log.info("\n\n=== V2 통합된 직업 로드맵 트리 구조 출력 ==="); + printJobRoadmapTree(integratedRoadmap); + log.info("=== V2 트리 구조 출력 완료 ===\n\n"); + } }