diff --git a/back/src/main/java/com/back/domain/node/controller/BaseLineController.java b/back/src/main/java/com/back/domain/node/controller/BaseLineController.java index 9868d70..4dfacaa 100644 --- a/back/src/main/java/com/back/domain/node/controller/BaseLineController.java +++ b/back/src/main/java/com/back/domain/node/controller/BaseLineController.java @@ -75,4 +75,15 @@ public ResponseEntity> getMyBaseLines( List list = nodeService.getMyBaseLines(me.getId()); return ResponseEntity.ok(list); } + + // 한줄 요약: 소유자 검증 후 베이스라인과 모든 연관 데이터 일괄 삭제 + @DeleteMapping("/{baseLineId}") + public ResponseEntity deleteBaseLine( + @AuthenticationPrincipal CustomUserDetails me, + @PathVariable Long baseLineId + ) { + if (me == null) throw new ApiException(ErrorCode.HANDLE_ACCESS_DENIED, "login required"); + nodeService.deleteBaseLineDeep(me.getId(), baseLineId); // 가장 많이 사용하는 호출: 파사드에 위임 + return ResponseEntity.noContent().build(); + } } diff --git a/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java b/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java index f98162f..a9aa817 100644 --- a/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java +++ b/back/src/main/java/com/back/domain/node/dto/decision/DecNodeDto.java @@ -7,7 +7,6 @@ import com.back.domain.node.entity.FollowPolicy; import com.back.domain.node.entity.NodeCategory; - import java.util.List; public record DecNodeDto( @@ -46,11 +45,14 @@ public record DecNodeDto( Boolean root, // 라인 헤더면 true Long pivotLinkBaseNodeId, // 베이스 분기 슬롯에서 올라온 첫 노드면 해당 BaseNode id Integer pivotSlotIndex, // 0/1 (분기 슬롯 인덱스), 아니면 null + Long pivotLinkDecisionNodeId, // 단일 패스 렌더용 최소 힌트 Integer renderPhase, // 1=from-base 라인, 2..N=fork 깊이(부모 라인 +1) Long incomingFromId, // 이 노드로 "들어오는" 에지의 from 노드 id(루트면 포크 원본, 아니면 parentId) - String incomingEdgeType // "normal" | "fork" + String incomingEdgeType, // "root","prelude","from-base","fork","normal" + // 포크 원본 라인 id(디버깅/렌더 보조용) + Long incomingFromLineId ) { // === 호환 오버로드(기존 서비스 호출 유지) === public DecNodeDto( @@ -70,6 +72,6 @@ public DecNodeDto( followPolicy, pinnedCommitId, virtual, effectiveCategory, effectiveSituation, effectiveDecision, effectiveOptions, effectiveDescription, null, null, null, null, - null, null, null); // 새 필드는 null 기본값 + null, null, null, null,null); // 새 필드는 null 기본값 } } diff --git a/back/src/main/java/com/back/domain/node/entity/BaseLine.java b/back/src/main/java/com/back/domain/node/entity/BaseLine.java index a3e933d..42c4166 100644 --- a/back/src/main/java/com/back/domain/node/entity/BaseLine.java +++ b/back/src/main/java/com/back/domain/node/entity/BaseLine.java @@ -1,5 +1,6 @@ package com.back.domain.node.entity; +import com.back.domain.scenario.entity.Scenario; import com.back.domain.user.entity.User; import com.back.global.baseentity.BaseEntity; import jakarta.persistence.*; @@ -35,6 +36,10 @@ public class BaseLine extends BaseEntity { @Builder.Default private List baseNodes = new ArrayList<>(); + @OneToMany(mappedBy = "baseLine", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List scenarios = new ArrayList<>(); + //중간 피벗 나이 목록 반환(헤더/꼬리 제외, 중복 제거, 오름차순) public List pivotAges() { List nodes = this.baseNodes; diff --git a/back/src/main/java/com/back/domain/node/entity/DecisionLine.java b/back/src/main/java/com/back/domain/node/entity/DecisionLine.java index ccb05c1..73c16d6 100644 --- a/back/src/main/java/com/back/domain/node/entity/DecisionLine.java +++ b/back/src/main/java/com/back/domain/node/entity/DecisionLine.java @@ -4,6 +4,7 @@ */ package com.back.domain.node.entity; +import com.back.domain.scenario.entity.Scenario; import com.back.domain.user.entity.User; import com.back.global.baseentity.BaseEntity; import jakarta.persistence.*; @@ -34,6 +35,9 @@ public class DecisionLine extends BaseEntity { @Column(nullable = false) private DecisionLineStatus status; + @Column(name = "parent_line_id") + private Long parentLineId; + @OneToMany(mappedBy = "decisionLine", cascade = CascadeType.ALL, orphanRemoval = true) private List decisionNodes = new ArrayList<>(); @@ -46,6 +50,9 @@ public class DecisionLine extends BaseEntity { @JoinColumn(name = "pinned_commit_id") private BaselineCommit pinnedCommit; + @OneToOne(mappedBy = "decisionLine", fetch = FetchType.LAZY) + private Scenario scenario; + // 라인 취소 상태 전이 public void cancel() { if (this.status == DecisionLineStatus.COMPLETED) { diff --git a/back/src/main/java/com/back/domain/node/entity/DecisionNode.java b/back/src/main/java/com/back/domain/node/entity/DecisionNode.java index 1738b7b..fb032f0 100644 --- a/back/src/main/java/com/back/domain/node/entity/DecisionNode.java +++ b/back/src/main/java/com/back/domain/node/entity/DecisionNode.java @@ -82,6 +82,12 @@ public class DecisionNode extends BaseEntity { @JoinColumn(name = "override_version_id") private NodeAtomVersion overrideVersion; + @Column(name = "ai_next_situation", columnDefinition = "TEXT") + private String aiNextSituation; + + @Column(name = "ai_next_recommended_option", columnDefinition = "TEXT") + private String aiNextRecommendedOption; + // 다음 나이 검증 public void guardNextAgeValid(int nextAge) { if (nextAge <= this.getAgeYear()) { @@ -94,4 +100,9 @@ public void setOverride(NodeAtomVersion version) { this.followPolicy = FollowPolicy.OVERRIDE; this.overrideVersion = version; } + + public void setAiHint(String nextSituation, String nextRecommended) { + this.aiNextSituation = nextSituation; + this.aiNextRecommendedOption = nextRecommended; + } } diff --git a/back/src/main/java/com/back/domain/node/mapper/NodeMappers.java b/back/src/main/java/com/back/domain/node/mapper/NodeMappers.java index c51f71a..82a4f88 100644 --- a/back/src/main/java/com/back/domain/node/mapper/NodeMappers.java +++ b/back/src/main/java/com/back/domain/node/mapper/NodeMappers.java @@ -128,8 +128,8 @@ public NodeMappers(VersionResolver resolver, e.getSelectedIndex(), e.getParentOptionIndex(), e.getDescription(), - null, - null, + e.getAiNextSituation(), + e.getAiNextRecommendedOption(), e.getFollowPolicy(), pinnedCommitId, null, // virtual 투영은 상위 레이어에서 주입 diff --git a/back/src/main/java/com/back/domain/node/repository/BaseLineRepository.java b/back/src/main/java/com/back/domain/node/repository/BaseLineRepository.java index 6b5ff13..6263073 100644 --- a/back/src/main/java/com/back/domain/node/repository/BaseLineRepository.java +++ b/back/src/main/java/com/back/domain/node/repository/BaseLineRepository.java @@ -32,4 +32,8 @@ public interface BaseLineRepository extends JpaRepository { // 사용자별 베이스라인 목록 조회 (페이징 및 N+1 방지) @Query("SELECT DISTINCT bl FROM BaseLine bl LEFT JOIN FETCH bl.baseNodes bn WHERE bl.user.id = :userId ORDER BY bl.createdDate DESC") Page findAllByUserIdWithBaseNodes(@Param("userId") Long userId, Pageable pageable); + + boolean existsByIdAndUser_Id(Long baseLineId, Long userId); + + void deleteByIdAndUser_Id(Long baseLineId, Long userId); } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/node/repository/BaseNodeRepository.java b/back/src/main/java/com/back/domain/node/repository/BaseNodeRepository.java index 22c2c45..161b8e0 100644 --- a/back/src/main/java/com/back/domain/node/repository/BaseNodeRepository.java +++ b/back/src/main/java/com/back/domain/node/repository/BaseNodeRepository.java @@ -39,4 +39,6 @@ public interface BaseNodeRepository extends JpaRepository { @Query("update BaseNode b set b.altOpt2TargetDecisionId = null " + "where b.id = :id and b.altOpt2TargetDecisionId = :targetId") int unlinkAlt2IfMatches(@Param("id") Long id, @Param("targetId") Long targetId); + + void deleteByBaseLine_Id(Long baseLineId); } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/node/repository/BaselineBranchRepository.java b/back/src/main/java/com/back/domain/node/repository/BaselineBranchRepository.java index ec20fd8..c72dbdb 100644 --- a/back/src/main/java/com/back/domain/node/repository/BaselineBranchRepository.java +++ b/back/src/main/java/com/back/domain/node/repository/BaselineBranchRepository.java @@ -7,6 +7,9 @@ import com.back.domain.node.entity.BaselineBranch; 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; import org.springframework.stereotype.Repository; import java.util.List; @@ -20,4 +23,10 @@ public interface BaselineBranchRepository extends JpaRepository findByBaseLine_Id(Long baseLineId); + + void deleteByBaseLine_Id(Long baseLineId); + + @Modifying + @Query("update BaselineBranch b set b.headCommit = null where b.baseLine.id = :baseLineId") + void clearHeadByBaseLineId(@Param("baseLineId") Long baseLineId); } diff --git a/back/src/main/java/com/back/domain/node/repository/BaselineCommitRepository.java b/back/src/main/java/com/back/domain/node/repository/BaselineCommitRepository.java index ef829be..c60b489 100644 --- a/back/src/main/java/com/back/domain/node/repository/BaselineCommitRepository.java +++ b/back/src/main/java/com/back/domain/node/repository/BaselineCommitRepository.java @@ -25,4 +25,6 @@ public interface BaselineCommitRepository extends JpaRepository findAll(); + + void deleteByBranch_BaseLine_Id(Long baseLineId); } diff --git a/back/src/main/java/com/back/domain/node/repository/BaselinePatchRepository.java b/back/src/main/java/com/back/domain/node/repository/BaselinePatchRepository.java index 072e53c..a7f0db3 100644 --- a/back/src/main/java/com/back/domain/node/repository/BaselinePatchRepository.java +++ b/back/src/main/java/com/back/domain/node/repository/BaselinePatchRepository.java @@ -21,4 +21,6 @@ public interface BaselinePatchRepository extends JpaRepository findByCommit_IdInAndAgeYearOrderByIdDesc(Collection commitIds, Integer ageYear); + + void deleteByCommit_Branch_BaseLine_Id(Long baseLineId); } diff --git a/back/src/main/java/com/back/domain/node/repository/DecisionLineRepository.java b/back/src/main/java/com/back/domain/node/repository/DecisionLineRepository.java index 5753d3c..1be7704 100644 --- a/back/src/main/java/com/back/domain/node/repository/DecisionLineRepository.java +++ b/back/src/main/java/com/back/domain/node/repository/DecisionLineRepository.java @@ -20,4 +20,5 @@ public interface DecisionLineRepository extends JpaRepository findWithUserById(Long id); + void deleteByBaseLine_Id(Long baseLineId); } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/node/repository/DecisionNodeRepository.java b/back/src/main/java/com/back/domain/node/repository/DecisionNodeRepository.java index 5fb2527..3b628ca 100644 --- a/back/src/main/java/com/back/domain/node/repository/DecisionNodeRepository.java +++ b/back/src/main/java/com/back/domain/node/repository/DecisionNodeRepository.java @@ -24,4 +24,6 @@ public interface DecisionNodeRepository extends JpaRepository findByDecisionLine_BaseLine_IdAndParentIsNull(Long baseLineId); + + void deleteByDecisionLine_BaseLine_Id(Long baseLineId); } diff --git a/back/src/main/java/com/back/domain/node/service/BaseLineService.java b/back/src/main/java/com/back/domain/node/service/BaseLineService.java index c557ae5..e75fd69 100644 --- a/back/src/main/java/com/back/domain/node/service/BaseLineService.java +++ b/back/src/main/java/com/back/domain/node/service/BaseLineService.java @@ -12,17 +12,24 @@ import com.back.domain.node.entity.*; import com.back.domain.node.mapper.NodeMappers; import com.back.domain.node.repository.*; +import com.back.domain.scenario.repository.ScenarioRepository; +import com.back.domain.scenario.repository.SceneCompareRepository; +import com.back.domain.scenario.repository.SceneTypeRepository; import com.back.domain.user.entity.Role; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; +import jakarta.persistence.EntityManager; 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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -31,15 +38,21 @@ class BaseLineService { private final BaseLineRepository baseLineRepository; private final BaseNodeRepository baseNodeRepository; + private final DecisionLineRepository decisionLineRepository; + private final DecisionNodeRepository decisionNodeRepository; private final UserRepository userRepository; private final NodeDomainSupport support; + private final ScenarioRepository scenarioRepository; + private final SceneTypeRepository sceneTypeRepository; + private final SceneCompareRepository sceneCompareRepository; // 하이브리드 초기화용 private final NodeAtomRepository atomRepo; private final NodeAtomVersionRepository versionRepo; private final BaselineBranchRepository branchRepo; private final BaselineCommitRepository commitRepo; - private final BaselinePatchRepository patchRepo; // ← 추가 + private final BaselinePatchRepository patchRepo; + private final EntityManager em; private final NodeMappers mappers; @@ -131,4 +144,48 @@ public PivotListDto getPivotBaseNodes(Long baseLineId) { } return new PivotListDto(baseLineId, list); } + + @Transactional + public void deleteBaseLineDeep(Long userId, Long baseLineId) { + // 가장 많이 사용하는 호출: 소유자/존재 여부 검증 + boolean owned = baseLineRepository.existsByIdAndUser_Id(baseLineId, userId); + if (!owned) throw new ApiException(ErrorCode.BASE_LINE_NOT_FOUND, "baseline not found or not owned"); + + // 가장 많이 사용하는 호출: 결정노드 → 결정라인 + decisionNodeRepository.deleteByDecisionLine_BaseLine_Id(baseLineId); + decisionLineRepository.deleteByBaseLine_Id(baseLineId); + + // 가장 많이 사용하는 호출: DVCS 역순(Patch→Commit→Branch) + patchRepo.deleteByCommit_Branch_BaseLine_Id(baseLineId); + branchRepo.clearHeadByBaseLineId(baseLineId); + commitRepo.deleteByBranch_BaseLine_Id(baseLineId); + branchRepo.deleteByBaseLine_Id(baseLineId); + + // (중간 플러시) 위 벌크 삭제 쿼리 확실히 반영 + em.flush(); + em.clear(); + + // 시나리오 삭제(자식 → 부모 순서) + List scenarioIds = scenarioRepository.findIdsByBaseLine_Id(baseLineId); + if (scenarioIds != null && !scenarioIds.isEmpty()) { + // 가장 많이 사용하는 호출: 시나리오 비교/타입(자식 테이블) 선삭제 + sceneCompareRepository.deleteByScenario_IdIn(scenarioIds); + sceneTypeRepository.deleteByScenario_IdIn(scenarioIds); + + // (중간 플러시) 자식이 먼저 사라진 것을 보장 + em.flush(); + em.clear(); + + // 부모(Scenario) 벌크 삭제 + scenarioRepository.deleteByIdIn(scenarioIds); + } + + // (중간 플러시) 시나리오 트리 정리 완료 보장 + em.flush(); + em.clear(); + + // 가장 많이 사용하는 호출: 베이스노드 → 베이스라인 + baseNodeRepository.deleteByBaseLine_Id(baseLineId); + baseLineRepository.deleteByIdAndUser_Id(baseLineId, userId); + } } diff --git a/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java b/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java index 1b4a1ca..2b4aed2 100644 --- a/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java +++ b/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java @@ -42,8 +42,6 @@ public class DecisionFlowService { @PersistenceContext private EntityManager entityManager; - - // 가장 중요한: from-base 생성 시 main 브랜치를 보장 @Transactional public DecNodeDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request) { @@ -135,6 +133,9 @@ public DecNodeDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request List orderedList = decisionNodeRepository.findByDecisionLine_IdOrderByAgeYearAscIdAsc(baseDto.decisionLineId()); var hint = aiVectorService.generateNextHint(baseDto.userId(), baseDto.decisionLineId(), orderedList); + saved.setAiHint(hint.aiNextSituation(), hint.aiNextRecommendedOption()); + decisionNodeRepository.save(saved); + return new DecNodeDto( baseDto.id(), baseDto.userId(), baseDto.type(), baseDto.category(), baseDto.situation(), baseDto.decision(), baseDto.ageYear(), @@ -166,10 +167,6 @@ private BaselineBranch ensureMainBranch(Long baseLineId, Long authorUserId) { }); } - - - - // next 서버 해석(부모 기준 라인/다음 피벗/베이스 매칭 결정) @Transactional public DecNodeDto createDecisionNodeNext(DecisionNodeNextRequest request) { @@ -225,6 +222,10 @@ public DecNodeDto createDecisionNodeNext(DecisionNodeNextRequest request) { .findByDecisionLine_IdOrderByAgeYearAscIdAsc(baseDto.decisionLineId()); var hint = aiVectorService.generateNextHint(baseDto.userId(), baseDto.decisionLineId(), ordered_decision); + + saved.setAiHint(hint.aiNextSituation(), hint.aiNextRecommendedOption()); + decisionNodeRepository.save(saved); + return new DecNodeDto( baseDto.id(), baseDto.userId(), baseDto.type(), baseDto.category(), baseDto.situation(), baseDto.decision(), baseDto.ageYear(), @@ -305,7 +306,6 @@ private Long currentUserId() { return cud.getUser().getId(); } - // 라인 완료 @Transactional public DecisionLineLifecycleDto completeDecisionLine(Long decisionLineId) { @@ -315,6 +315,7 @@ public DecisionLineLifecycleDto completeDecisionLine(Long decisionLineId) { } // 가장 중요한 함수: 포크 지점 생성 시 옵션 교체(from-base와 동일 규칙) 반영 + // 가장 중요한 함수 한줄 요약: 기존 라인을 부모로 하여 특정 결정노드에서 포크 라인을 만들고 AI 힌트를 엔티티에도 저장 @Transactional public DecNodeDto forkFromDecision(ForkFromDecisionRequest req) { if (req == null || req.parentDecisionNodeId() == null) @@ -322,62 +323,77 @@ public DecNodeDto forkFromDecision(ForkFromDecisionRequest req) { if (req.targetOptionIndex() == null || req.targetOptionIndex() < 0 || req.targetOptionIndex() > 2) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "targetOptionIndex out of range"); + // 가장 많이 사용하는 호출 한줄 요약: 부모 노드 로드 DecisionNode parent = decisionNodeRepository.findById(req.parentDecisionNodeId()) - .orElseThrow(() -> new ApiException(ErrorCode.NODE_NOT_FOUND, "Parent DecisionNode not found: " + req.parentDecisionNodeId())); + .orElseThrow(() -> new ApiException(ErrorCode.NODE_NOT_FOUND, + "Parent DecisionNode not found: " + req.parentDecisionNodeId())); DecisionLine originLine = parent.getDecisionLine(); + // ★ 새 라인에 원본 라인 id 저장(나머지 흐름 동일) DecisionLine newLine = decisionLineRepository.save( DecisionLine.builder() .user(originLine.getUser()) .baseLine(originLine.getBaseLine()) .baseBranch(originLine.getBaseBranch()) .status(DecisionLineStatus.DRAFT) + .parentLineId(originLine.getId()) .build() ); List orderedBase = support.getOrderedBaseNodes(originLine.getBaseLine().getId()); - DecisionNode prevNew = null; - boolean parentIsHead = (parent.getParent() == null); - if (!parentIsHead) { - prevNew = createDecisionLineHead(newLine, orderedBase); + // 가장 중요한 함수 위에 한줄로만 요약 주석: 베이스 헤더(BaseNode.parent==null) 선별 + BaseNode baseHeader = null; + for (BaseNode b : orderedBase) { + if (b.getParent() == null) { baseHeader = b; break; } } + if (baseHeader == null && !orderedBase.isEmpty()) baseHeader = orderedBase.get(0); + + DecisionNode prevNew = null; List orderedOrigin = decisionNodeRepository - // 가장 많이 사용하는 함수 호출 위에 한줄로만 요약 주석: 기존 라인의 노드 목록을 타임라인 정렬로 로드 + // 가장 많이 사용하는 호출 한줄 요약: 기존 라인의 노드 목록을 타임라인 정렬로 로드 .findByDecisionLine_IdOrderByAgeYearAscIdAsc(originLine.getId()); DecNodeDto forkPointDto = null; + // 가장 많이 사용하는 호출 한줄 요약: 포크 앵커 엔티티를 추적해 AI 힌트 저장에 사용 + DecisionNode forkAnchorSaved = null; for (DecisionNode n : orderedOrigin) { boolean isBeforeParent = n.getAgeYear() < parent.getAgeYear(); boolean isParent = n.getId().equals(parent.getId()); - - if (parentIsHead && !isParent) continue; - if (!parentIsHead && !isBeforeParent && !isParent) break; + if (!isBeforeParent && !isParent) break; BaseNode matchedBase = null; - try { matchedBase = support.findBaseNodeByAge(orderedBase, n.getAgeYear()); } catch (RuntimeException ignore) {} + try { + // 가장 많이 사용하는 호출 한줄 요약: 헤더면 베이스 헤더로, 아니면 age로 매칭 + if (n.getParent() == null) { + matchedBase = baseHeader; // 헤더는 항상 베이스 헤더로 고정 + } else { + matchedBase = support.findBaseNodeByAge(orderedBase, n.getAgeYear()); + } + } catch (RuntimeException ignore) {} - // ▼ 기본 옵션/선택은 원본 라인의 것을 사용 + // 기본 옵션/선택은 원본 라인의 것을 사용 List options = support.extractOptions(n); Integer selIdx = n.getSelectedIndex(); if (isParent) { - // ▼ 포크 지점이면 선택지 교체 로직 적용 (from-base와 동일 UX) + // ★ 포크 앵커에서 선택지 교체/강제 규칙 if (req.options() != null && !req.options().isEmpty()) { - // 1) 옵션 검증(1~3, selectedIndex와 일치) - support.validateOptions(req.options(), req.selectedIndex(), + // 가장 많이 사용하는 호출 한줄 요약: 옵션 검증(1~3개, selectedIndex 일치) + support.validateOptions( + req.options(), + req.selectedIndex(), (req.selectedIndex() != null && req.selectedIndex() >= 0 && req.selectedIndex() < req.options().size()) ? req.options().get(req.selectedIndex()) : null ); options = req.options(); - selIdx = req.selectedIndex(); - if (selIdx == null && options.size() == 1) selIdx = 0; // 단일 옵션 생략 시 0 고정 + selIdx = (req.selectedIndex() == null && options.size() == 1) ? 0 : req.selectedIndex(); } else { - // 2) 입력 옵션이 없으면 기존 옵션 사용 + targetOptionIndex만 강제 + // 입력 옵션이 없으면 기존 옵션 사용 + targetOptionIndex 강제 if (options == null || options.isEmpty()) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "fork requires options at parent node"); if (req.targetOptionIndex() >= options.size()) @@ -396,7 +412,10 @@ public DecNodeDto forkFromDecision(ForkFromDecisionRequest req) { NodeMappers.DecisionNodeCtxMapper mapper = mappers.new DecisionNodeCtxMapper(n.getUser(), newLine, prevNew, matchedBase, background); - Long newParentId = (isParent && parentIsHead) ? null : (prevNew != null ? prevNew.getId() : null); + Long newParentId = (n.getParent() == null) ? null : (prevNew != null ? prevNew.getId() : null); + + // ★★★★★ 핵심(원본 유지): 포크 앵커에서 parentOptionIndex 강제 → 라벨러가 'fork'로 인식 + Integer parentOptionIndexForCreate = isParent ? req.targetOptionIndex() : null; DecisionNodeCreateRequestDto createReq = new DecisionNodeCreateRequestDto( newLine.getId(), @@ -406,9 +425,9 @@ public DecNodeDto forkFromDecision(ForkFromDecisionRequest req) { situation, finalDecision, n.getAgeYear(), - options, // ← 여기서 교체 반영된 options 사용 - selIdx, // ← 선택 인덱스 반영 - (prevNew != null ? prevNew.getSelectedIndex() : null), + options, + selIdx, + parentOptionIndexForCreate, n.getDescription() ); @@ -417,17 +436,24 @@ public DecNodeDto forkFromDecision(ForkFromDecisionRequest req) { if (isParent) { forkPointDto = mapper.toResponse(saved); + forkAnchorSaved = saved; // AI 힌트 저장 대상으로 보관 } } if (forkPointDto == null) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "fork parent not materialized"); - // (선택) from-base/next와 UX 통일: aiNext* 주입 - List orderedNew = decisionNodeRepository - .findByDecisionLine_IdOrderByAgeYearAscIdAsc(forkPointDto.decisionLineId()); + // 가장 많이 사용하는 호출 한줄 요약: 새 라인의 노드를 타임라인 정렬로 재조회하여 AI 힌트 생성 + List orderedNew = + decisionNodeRepository.findByDecisionLine_IdOrderByAgeYearAscIdAsc(forkPointDto.decisionLineId()); var hint = aiVectorService.generateNextHint(forkPointDto.userId(), forkPointDto.decisionLineId(), orderedNew); + // 가장 많이 사용하는 호출 한줄 요약: 생성된 AI 힌트를 앵커 엔티티에 저장(영속) + if (forkAnchorSaved != null) { + forkAnchorSaved.setAiHint(hint.aiNextSituation(), hint.aiNextRecommendedOption()); + decisionNodeRepository.save(forkAnchorSaved); + } + return new DecNodeDto( forkPointDto.id(), forkPointDto.userId(), forkPointDto.type(), forkPointDto.category(), forkPointDto.situation(), forkPointDto.decision(), forkPointDto.ageYear(), @@ -442,6 +468,7 @@ public DecNodeDto forkFromDecision(ForkFromDecisionRequest req) { ); } + // 베이스 헤더를 찾아 결정 라인 헤더 생성 private DecisionNode createDecisionLineHead(DecisionLine line, List orderedBase) { if (orderedBase == null || orderedBase.isEmpty()) return null; diff --git a/back/src/main/java/com/back/domain/node/service/NodeQueryService.java b/back/src/main/java/com/back/domain/node/service/NodeQueryService.java index 6d7dc4b..93a485d 100644 --- a/back/src/main/java/com/back/domain/node/service/NodeQueryService.java +++ b/back/src/main/java/com/back/domain/node/service/NodeQueryService.java @@ -25,7 +25,6 @@ import org.springframework.transaction.annotation.Transactional; import java.util.*; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -46,135 +45,243 @@ public class NodeQueryService { private final ObjectMapper objectMapper; - // 가장 많이 사용하는: 특정 BaseLine 전체 트리에도 동일한 편의 필드 주입 + /* + * [TreeQuery] BaseLine 트리 조회 (하드닝 적용판) + * - 목적: 포크 앵커의 pivotLinkDecisionNodeId가 반드시 (background|ageYear|parentLineId)로만 매칭되도록 강제 + * - 흐름 개요 + * 1) 베이스/라인/노드 조회 및 DTO 매핑 → 보조 인덱스(pivot/children) 구축 + * 2) 라인별 메타 계산: indexInLine, 첫 from-base, from-base 라인 여부, 첫 fork 앵커 + * 3) 원본 normal 인덱스 구축: key = (background|ageYear|lineId) + 중복(normal) 검증 + * 4) 포크 라인의 parentLineId 일관성 검증(from-base 라인은 null, 포크 라인은 not null) + * 5) 각 노드에 edge 라벨(root/from-base/prelude/fork/normal)·pivotLink 채움 + * - 포크 앵커의 결정 피벗 링크는 (background|ageYear|parentLineId)로 '정확 매칭', 미스면 즉시 예외 + * 6) renderPhase(라인 위상) → ageYear → id 기준 정렬 후 TreeDto 반환 + */ + + // 가장 중요한 함수 한줄 요약: BaseLine 전체 트리를 라벨링/피벗링크/검증 강화하여 TreeDto로 반환 public TreeDto getTreeForBaseLine(Long baseLineId) { + // 가장 많이 사용하는 호출 한줄 요약: 베이스라인 존재 보장 support.ensureBaseLineExists(baseLineId); + // 가장 많이 사용하는 호출 한줄 요약: BaseNode 정렬 조회 및 DTO 매핑 List orderedBase = support.getOrderedBaseNodes(baseLineId); - List baseDtos = orderedBase.stream().map(mappers.BASE_READ::map).toList(); + List baseDtos = orderedBase.stream() + // 가장 많이 사용하는 호출 한줄 요약: BaseNode → BaseNodeDto 매핑 + .map(mappers.BASE_READ::map) + .toList(); - // 가장 많이 사용하는: 전체 라인 공용 pivot 역인덱스 Map pivotIndex = buildPivotIndex(baseLineId); - // ===== (1) 노드 수집 ===== - record Key(Long baseId, Integer age) {} - record View(DecisionNode dn, DecNodeDto dto, boolean isRoot, Long baseId, Integer age, - List childrenIds, Long pivotBaseId, Integer pivotSlot) {} - - List decDtos = new ArrayList<>(); + record View( + DecisionNode dn, + DecNodeDto dto, + boolean isRoot, + Long baseId, + Integer age, + List childrenIds, + Integer pivotSlot + ) {} + + // 가장 많이 사용하는 호출 한줄 요약: 베이스라인의 모든 라인 조회 List lines = decisionLineRepository.findByBaseLine_Id(baseLineId); + // ★ 라인 메타(parentLineId) 사전 구축 + Map parentLineIdByLine = new HashMap<>(); + for (DecisionLine ln : lines) parentLineIdByLine.put(ln.getId(), ln.getParentLineId()); + List pool = new ArrayList<>(); Map> byLine = new HashMap<>(); for (DecisionLine line : lines) { - // 가장 많이 사용하는 함수 호출 위에 한줄로만 요약 주석: 라인의 노드를 타임라인 정렬로 로드 List ordered = decisionNodeRepository + // 가장 많이 사용하는 호출 한줄 요약: 라인의 노드를 타임라인 정렬로 조회 .findByDecisionLine_IdOrderByAgeYearAscIdAsc(line.getId()); - Map> childrenIndex = buildChildrenIndex(ordered); for (DecisionNode dn : ordered) { - DecNodeDto base = mappers.DECISION_READ.map(dn); + // 가장 많이 사용하는 호출 한줄 요약: DecisionNode → DecNodeDto 매핑 + DecNodeDto dto = mappers.DECISION_READ.map(dn); boolean isRoot = (dn.getParent() == null); List childrenIds = childrenIndex.getOrDefault(dn.getId(), List.of()); - PivotMark mark = pivotIndex.get(base.id()); - Long pivotBaseId = (mark != null) ? mark.baseNodeId() : - (dn.getBaseNode() != null ? dn.getBaseNode().getId() : null); + PivotMark mark = pivotIndex.get(dto.id()); + Long pivotBaseId = (mark != null) ? mark.baseNodeId() + : (dn.getBaseNode() != null ? dn.getBaseNode().getId() : null); Integer pivotSlot = (mark != null) ? mark.slotIndex() : null; - Long baseId = pivotBaseId; - Integer age = dn.getAgeYear(); - - View v = new View(dn, base, isRoot, baseId, age, List.copyOf(childrenIds), pivotBaseId, pivotSlot); + View v = new View( + dn, dto, isRoot, + pivotBaseId, dn.getAgeYear(), + List.copyOf(childrenIds), pivotSlot + ); pool.add(v); byLine.computeIfAbsent(line.getId(), k -> new ArrayList<>()).add(v); } } - Map> byKey = pool.stream() - .filter(v -> v.baseId != null && v.age != null) - .collect(Collectors.groupingBy(v -> new Key(v.baseId, v.age))); + // 1) 라인별 보조 인덱스: 노드 순서, 첫 from-base, from-base 라인 여부, 첫 fork 앵커 + Map> indexInLine = new HashMap<>(); + Map firstFromBaseNodeIdByLine = new HashMap<>(); + Map isFromBaseLine = new HashMap<>(); + Map firstForkNodeIdByLine = new HashMap<>(); - // ===== (2) 라인 간 포크 그래프 추론 → renderPhase 계산 ===== - Map rootCand = new HashMap<>(); // lineId -> 루트 후보 for (Map.Entry> e : byLine.entrySet()) { + Long lineId = e.getKey(); List vs = e.getValue().stream() - .sorted(Comparator.comparing((View x) -> x.age) - .thenComparing(x -> x.dn.getId())) + .sorted(Comparator.comparing((View x) -> x.age).thenComparing(x -> x.dn.getId())) .toList(); - View first = vs.get(0); - // 가장 중요한 함수: 라인 루트가 헤더면 다음 피벗을 루트 후보로 대체 - View cand = (first.isRoot && first.baseId == null && vs.size() > 1) ? vs.get(1) : first; - rootCand.put(e.getKey(), cand); - } + Map idx = new HashMap<>(); + for (int i = 0; i < vs.size(); i++) idx.put(vs.get(i).dn.getId(), i); + indexInLine.put(lineId, idx); - Map> g = new HashMap<>(); // originLineId -> {forkLineId} - Map indeg = new HashMap<>(); // lineId -> indegree + // from-base 라인: pivotSlot 있는 첫 노드(여러 번 from-base 가능해도 라인 판정은 첫 발생으로 충분) + Long firstFromBase = null; + for (View v : vs) { + if (v.pivotSlot != null) { firstFromBase = v.dn.getId(); break; } + } + if (firstFromBase != null) { + firstFromBaseNodeIdByLine.put(lineId, firstFromBase); + isFromBaseLine.put(lineId, true); + } else { + isFromBaseLine.put(lineId, false); + } - for (Map.Entry e : rootCand.entrySet()) { - Long lineId = e.getKey(); - View me = e.getValue(); - indeg.putIfAbsent(lineId, 0); - - if (me.baseId != null && me.age != null) { - List same = byKey.getOrDefault(new Key(me.baseId, me.age), List.of()); - Optional origin = same.stream() - .filter(o -> !o.dn.getDecisionLine().getId().equals(lineId)) - .sorted(Comparator - .comparing((View o) -> o.dn.getDecisionLine().getId()) - .thenComparing(o -> o.dn.getId())) - .findFirst(); - - if (origin.isPresent()) { - Long originLineId = origin.get().dn.getDecisionLine().getId(); - g.computeIfAbsent(originLineId, k -> new HashSet<>()).add(lineId); - indeg.put(lineId, indeg.getOrDefault(lineId, 0) + 1); - indeg.putIfAbsent(originLineId, 0); + // fork 라인의 "첫 fork 노드": parentOptionIndex != null 인 첫 노드 (from-base 라인 제외) + if (!isFromBaseLine.get(lineId)) { + for (View v : vs) { + if (v.dto.parentOptionIndex() != null) { + firstForkNodeIdByLine.put(lineId, v.dn.getId()); + break; + } } } } - Map linePhase = new HashMap<>(); // lineId -> phase - ArrayDeque q = new ArrayDeque<>(); - for (Map.Entry e : indeg.entrySet()) { - if (e.getValue() == 0) { // indegree==0 → from-base - linePhase.put(e.getKey(), 1); - q.add(e.getKey()); + // 1-보강) parentLineId 일관성 검증: from-base 라인은 null, 포크 라인은 not null(앵커 존재 시) + for (Long lineId : byLine.keySet()) { + boolean hasForkAnchor = firstForkNodeIdByLine.containsKey(lineId); + Long pli = parentLineIdByLine.get(lineId); + + if (Boolean.TRUE.equals(isFromBaseLine.getOrDefault(lineId, false))) { + if (pli != null) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "from-base line must have null parentLineId: line=" + lineId); + } else { + if (hasForkAnchor && pli == null) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "fork line without parentLineId: line=" + lineId); } } - while (!q.isEmpty()) { - Long u = q.poll(); - int next = linePhase.get(u) + 1; - for (Long v : g.getOrDefault(u, Set.of())) { - indeg.put(v, indeg.get(v) - 1); - linePhase.put(v, Math.max(linePhase.getOrDefault(v, 1), next)); - if (indeg.get(v) == 0) q.add(v); + + // 2) 원본 normal 노드 인덱스(A안 핵심): key = background + "|" + ageYear + "|" + lineId + // parentOptionIndex == null 인 노드(= normal)만 수집, 동일 키는 가장 오래된(작은 id) 고정 + Map sourceNormalByKey = new HashMap<>(); + Map sourceNormalCount = new HashMap<>(); // 중복(normal) 감지 + for (View v : pool) { + DecNodeDto d = v.dto; + String bg = d.background(); + Integer age = d.ageYear(); + Long ln = v.dn.getDecisionLine().getId(); + if (bg != null && age != null && d.parentOptionIndex() == null) { + String k = bg + "|" + age + "|" + ln; // ★ lineId 포함(라인 분리 키) + sourceNormalByKey.merge(k, v.dn.getId(), Math::min); + sourceNormalCount.merge(k, 1, Integer::sum); } } + // 2-보강) 같은 라인에서 동일 (bg,age) normal이 2개 이상이면 모호성 → 예외 + for (Map.Entry e : sourceNormalCount.entrySet()) { + if (e.getValue() > 1) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "ambiguous normals for key=" + e.getKey()); + } + + // 3) renderPhase (정렬용 최소치): from-base 라인=1, 그 외(일반/포크)=2 + Map linePhase = new HashMap<>(); + for (Long lineId : byLine.keySet()) { + linePhase.put(lineId, isFromBaseLine.getOrDefault(lineId, false) ? 1 : 2); + } - // ===== (3) DTO 주입: renderPhase + incomingFromId/incomingEdgeType ===== + // 4) DTO 생성 (edge 라벨 + pivotLink 채우기) + List decDtos = new ArrayList<>(); for (View v : pool) { DecNodeDto b = v.dto; Long lineId = v.dn.getDecisionLine().getId(); Integer renderPhase = linePhase.getOrDefault(lineId, 1); - Long incomingFromId = (v.isRoot) - ? byKey.getOrDefault(new Key(v.baseId, v.age), List.of()).stream() - .filter(o -> !o.dn.getDecisionLine().getId().equals(lineId)) - .sorted(Comparator - .comparing((View o) -> o.dn.getDecisionLine().getId()) - .thenComparing(o -> o.dn.getId())) - .map(o -> o.dn.getId()) - .findFirst() - .orElse(null) - : (v.dn.getParent() != null ? v.dn.getParent().getId() : null); + Long incomingFromId; + Long incomingFromLineId = null; + if (v.isRoot) { + incomingFromId = null; // 루트는 외부에서 들어온 에지가 없다(표시용 null) + } else { + DecisionNode p = v.dn.getParent(); + incomingFromId = (p != null ? p.getId() : null); + incomingFromLineId = (p != null ? p.getDecisionLine().getId() : null); + } + + // === edge type 규칙 === + String incomingEdgeType; + if (v.isRoot) { + incomingEdgeType = "root"; + } else if (v.pivotSlot != null) { + // from-base 표시가 있는 노드는 무조건 from-base (여러 번 존재해도 동일 규칙) + incomingEdgeType = "from-base"; + } else { + Long firstForkId = firstForkNodeIdByLine.get(lineId); + Map idxMap = indexInLine.getOrDefault(lineId, Map.of()); + Integer curIdx = idxMap.getOrDefault(v.dn.getId(), Integer.MAX_VALUE); + Integer forkIdx = (firstForkId != null) ? idxMap.getOrDefault(firstForkId, Integer.MAX_VALUE) : null; + + boolean sameLineFromParent = Objects.equals(incomingFromLineId, lineId); + boolean isForkAnchor = (firstForkId != null) && Objects.equals(firstForkId, v.dn.getId()); + boolean beforeFirstFork = (firstForkId != null) && sameLineFromParent && (curIdx < forkIdx); + + if (!Boolean.TRUE.equals(isFromBaseLine.getOrDefault(lineId, false))) { + // 포크 라인 규칙: 앵커=fork, 앵커 이전은 prelude, 나머지 normal + if (isForkAnchor) { + incomingEdgeType = "fork"; + } else if (beforeFirstFork) { + incomingEdgeType = "prelude"; + } else { + incomingEdgeType = "normal"; + } + } else { + // from-base 라인 규칙: '첫 from-base' 이전만 prelude, 이후는 normal + Long firstFromBaseId = firstFromBaseNodeIdByLine.get(lineId); + Integer firstIdx = idxMap.getOrDefault(firstFromBaseId, Integer.MAX_VALUE); + boolean isPreludeOnFromBase = (firstFromBaseId != null) + && sameLineFromParent + && (curIdx < firstIdx); + incomingEdgeType = isPreludeOnFromBase ? "prelude" : "normal"; + } + } - String incomingEdgeType = (v.isRoot && incomingFromId != null) ? "fork" : "normal"; + // === pivotLink 채우기 === + // 1) 베이스 피벗 링크: from-base 표시가 있는 노드에만 설정 + Long pivotLinkBaseNodeId = (v.pivotSlot != null) ? v.baseId : null; + Integer pivotSlot = v.pivotSlot; + + // 2) 결정 피벗 링크(하드닝 핵심): + // 포크 앵커에 한해, 반드시 (background|ageYear|parentLineId)로 '정확 매칭' + // - parentLineId가 없거나, 매칭되는 normal이 없으면 즉시 예외(폴백 없음) + Long pivotLinkDecisionNodeId = null; + { + Long firstForkId = firstForkNodeIdByLine.get(lineId); + boolean isForkAnchor = (firstForkId != null) && Objects.equals(firstForkId, v.dn.getId()); + if (isForkAnchor) { + Long originLineId = parentLineIdByLine.get(lineId); + if (originLineId == null) { + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, + "fork anchor without parentLineId: line=" + lineId); + } + // 가장 많이 사용하는 호출 한줄 요약: 원본 라인의 normal을 (bg|age|originLineId)로 정확 조회 + String kOrigin = b.background() + "|" + b.ageYear() + "|" + originLineId; + pivotLinkDecisionNodeId = sourceNormalByKey.get(kOrigin); + if (pivotLinkDecisionNodeId == null) { + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, + "origin normal not found for fork anchor: key=" + kOrigin); + } + } + } decDtos.add(new DecNodeDto( b.id(), b.userId(), b.type(), b.category(), @@ -186,12 +293,13 @@ record View(DecisionNode dn, DecNodeDto dto, boolean isRoot, Long baseId, Intege b.followPolicy(), b.pinnedCommitId(), b.virtual(), b.effectiveCategory(), b.effectiveSituation(), b.effectiveDecision(), b.effectiveOptions(), b.effectiveDescription(), - v.childrenIds, v.isRoot, v.pivotBaseId, v.pivotSlot, - renderPhase, incomingFromId, incomingEdgeType + v.childrenIds, v.isRoot, pivotLinkBaseNodeId, pivotSlot, + pivotLinkDecisionNodeId, + renderPhase, incomingFromId, incomingEdgeType, + incomingFromLineId )); } - // 출력 순서 보장: phase → ageYear → id decDtos.sort(Comparator .comparing(DecNodeDto::renderPhase, Comparator.nullsLast(Integer::compareTo)) .thenComparing(DecNodeDto::ageYear, Comparator.nullsFirst(Integer::compareTo)) @@ -200,6 +308,8 @@ record View(DecisionNode dn, DecNodeDto dto, boolean isRoot, Long baseId, Intege return new TreeDto(baseDtos, decDtos); } + + // 라인별 베이스 노드 정렬 반환 public List getBaseLineNodes(Long baseLineId) { support.ensureBaseLineExists(baseLineId); @@ -307,6 +417,9 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) { Integer renderPhase = 1; Long incomingFromId = isRoot ? null : n.getParent().getId(); String incomingEdgeType = "normal"; + Long incomingFromLineId = isRoot ? null : n.getParent().getDecisionLine().getId(); + Long pivotLinkDecisionNodeId = null; + return new DecNodeDto( base.id(), base.userId(), base.type(), base.category(), @@ -319,8 +432,9 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) { // effective* (최종 해석 반영) effCategory, effSituation, effDecision, effOpts, effDesc, // ▼ 렌더 편의 + 단일 패스 힌트(라인 내부 한정) - List.copyOf(childrenIds), isRoot, pivotBaseId, pivotSlot, - renderPhase, incomingFromId, incomingEdgeType + List.copyOf(childrenIds), isRoot, pivotBaseId, + pivotSlot, pivotLinkDecisionNodeId, + renderPhase, incomingFromId, incomingEdgeType,incomingFromLineId ); }).toList(); @@ -390,4 +504,45 @@ private Map buildPivotIndex(Long baseLineId) { // 가장 중요한 함수 위에 한줄 요약: 분기 표식 컨테이너(베이스 id와 슬롯 인덱스) private record PivotMark(Long baseNodeId, Integer slotIndex) {} + + /* + * [요약 블럭] 트리 조회 계산용 스냅샷 뷰 + * - 엔티티→DTO 주입 전, 렌더 계산에 필요한 파생값(루트여부, baseId/age, pivot, children)을 묶어 캐시 + * - record 대신 정적 내부 클래스로 정의해 IDE/언어레벨/이름충돌 이슈 제거 + */ + private static final class NodeView { + private final DecisionNode dn; + private final DecNodeDto dto; + private final boolean isRoot; + private final Long baseId; + private final Integer age; + private final List childrenIds; + private final Long pivotBaseId; + private final Integer pivotSlot; + + NodeView(DecisionNode dn, DecNodeDto dto, boolean isRoot, + Long baseId, Integer age, List childrenIds, + Long pivotBaseId, Integer pivotSlot) { + this.dn = dn; + this.dto = dto; + this.isRoot = isRoot; + this.baseId = baseId; + this.age = age; + this.childrenIds = childrenIds; + this.pivotBaseId = pivotBaseId; + this.pivotSlot = pivotSlot; + } + + // 원본 엔티티 접근 + DecisionNode dn() { return dn; } + // DTO 스냅샷 접근 + DecNodeDto dto() { return dto; } + + boolean isRoot() { return isRoot; } + Long baseId() { return baseId; } + Integer age() { return age; } + List childrenIds() { return childrenIds; } + Long pivotBaseId() { return pivotBaseId; } + Integer pivotSlot() { return pivotSlot; } + } } diff --git a/back/src/main/java/com/back/domain/node/service/NodeService.java b/back/src/main/java/com/back/domain/node/service/NodeService.java index 55d9c30..e48d11f 100644 --- a/back/src/main/java/com/back/domain/node/service/NodeService.java +++ b/back/src/main/java/com/back/domain/node/service/NodeService.java @@ -79,4 +79,9 @@ public DecNodeDto forkFromDecision(ForkFromDecisionRequest request) { public List getMyBaseLines(Long id) { return nodeQueryService.getMyBaseLines(id); } + + // 소유자 검증 포함 베이스라인 깊은 삭제 위임 + public void deleteBaseLineDeep(Long userId, Long baseLineId) { + baseLineService.deleteBaseLineDeep(userId, baseLineId); + } } diff --git a/back/src/main/java/com/back/domain/scenario/repository/ScenarioRepository.java b/back/src/main/java/com/back/domain/scenario/repository/ScenarioRepository.java index 1882937..4de456c 100644 --- a/back/src/main/java/com/back/domain/scenario/repository/ScenarioRepository.java +++ b/back/src/main/java/com/back/domain/scenario/repository/ScenarioRepository.java @@ -5,10 +5,12 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; 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; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; /** @@ -45,4 +47,11 @@ Page findByUserIdAndDecisionLineIsNotNullAndStatusOrderByCreatedDateDe Optional findByUserIdAndRepresentativeTrue(Long userId); boolean existsByDecisionLine_Id(Long decisionLineId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM Scenario s WHERE s.id IN :scenarioIds") + void deleteByIdIn(List scenarioIds); + + @Query("select s.id from Scenario s where s.baseLine.id = :baseLineId") + List findIdsByBaseLine_Id(@Param("baseLineId") Long baseLineId); } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/scenario/repository/SceneCompareRepository.java b/back/src/main/java/com/back/domain/scenario/repository/SceneCompareRepository.java index e672e30..a1d8157 100644 --- a/back/src/main/java/com/back/domain/scenario/repository/SceneCompareRepository.java +++ b/back/src/main/java/com/back/domain/scenario/repository/SceneCompareRepository.java @@ -2,8 +2,10 @@ import com.back.domain.scenario.entity.SceneCompare; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; /** @@ -14,4 +16,6 @@ public interface SceneCompareRepository extends JpaRepository findByScenarioIdOrderByResultType(Long scenarioId); + @Modifying(clearAutomatically = true, flushAutomatically = true) + void deleteByScenario_IdIn(Collection scenarioIds); } diff --git a/back/src/main/java/com/back/domain/scenario/repository/SceneTypeRepository.java b/back/src/main/java/com/back/domain/scenario/repository/SceneTypeRepository.java index 5c18aef..97f0941 100644 --- a/back/src/main/java/com/back/domain/scenario/repository/SceneTypeRepository.java +++ b/back/src/main/java/com/back/domain/scenario/repository/SceneTypeRepository.java @@ -2,10 +2,12 @@ import com.back.domain.scenario.entity.SceneType; 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; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; /** @@ -25,4 +27,6 @@ public interface SceneTypeRepository extends JpaRepository { List findByScenarioIdInOrderByScenarioIdAscTypeAsc(@Param("scenarioIds") List scenarioIds); List findByScenarioId(Long scenarioId); + @Modifying(clearAutomatically = true, flushAutomatically = true) + void deleteByScenario_IdIn(Collection scenarioIds); } \ No newline at end of file diff --git a/back/src/main/resources/db/migration/V5__add_field_node_optimization.sql b/back/src/main/resources/db/migration/V5__add_field_node_optimization.sql new file mode 100644 index 0000000..be58f6a --- /dev/null +++ b/back/src/main/resources/db/migration/V5__add_field_node_optimization.sql @@ -0,0 +1,10 @@ +-- ============================================== +-- DecisionLine 및 DecisionNode 테이블 필드 추가 +-- ============================================== + +-- DecisionLine에 parent_line_id 필드 추가 +ALTER TABLE decision_lines ADD COLUMN IF NOT EXISTS parent_line_id BIGINT; + +-- DecisionNode에 situation 관련 필드 추가 +ALTER TABLE decision_nodes ADD COLUMN IF NOT EXISTS ai_next_situation TEXT; +ALTER TABLE decision_nodes ADD COLUMN IF NOT EXISTS ai_next_recommended_option TEXT; \ No newline at end of file diff --git a/back/src/test/java/com/back/domain/node/controller/BaseLineControllerTest.java b/back/src/test/java/com/back/domain/node/controller/BaseLineControllerTest.java index bf86330..40258b3 100644 --- a/back/src/test/java/com/back/domain/node/controller/BaseLineControllerTest.java +++ b/back/src/test/java/com/back/domain/node/controller/BaseLineControllerTest.java @@ -31,13 +31,14 @@ import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -135,7 +136,7 @@ private String sampleLineJson(Long uid) { {"category":"%s","situation":"고등학교 졸업","decision":"고등학교 졸업","ageYear":18}, {"category":"%s","situation":"대학 입학","decision":"대학 입학","ageYear":20}, {"category":"%s","situation":"첫 인턴","decision":"첫 인턴","ageYear":22}, - {"category":"%s","situation":"결말","decision":"결말","ageYear":24} + {"category":"%s","situation":"두번째 인턴","decision":"두번째 인턴","ageYear":24} ] } """.formatted(uid, @@ -160,7 +161,7 @@ void success_bulkCreateLine() throws Exception { .content(payload)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.baseLineId").exists()) - .andExpect(jsonPath("$.nodes.length()").value(4)); + .andExpect(jsonPath("$.nodes.length()").value(6)); } @Test @@ -306,12 +307,12 @@ void success_readLine_sortedByAge() throws Exception { var res = mockMvc.perform(get("/api/v1/base-lines/{id}/nodes", baseLineId) .with(authed(userId))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(4)) + .andExpect(jsonPath("$.length()").value(6)) .andReturn(); JsonNode arr = om.readTree(res.getResponse().getContentAsString()); - assertThat(arr.get(0).get("ageYear").asInt()).isEqualTo(18); - assertThat(arr.get(3).get("ageYear").asInt()).isEqualTo(24); + assertThat(arr.get(1).get("ageYear").asInt()).isEqualTo(18); + assertThat(arr.get(3).get("ageYear").asInt()).isEqualTo(22); } @Test @@ -455,13 +456,13 @@ void success_tree_noDecision() throws Exception { var res = mockMvc.perform(get("/api/v1/base-lines/{id}/tree", baseLineId) .with(authed(userId))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.baseNodes.length()").value(4)) + .andExpect(jsonPath("$.baseNodes.length()").value(6)) .andExpect(jsonPath("$.decisionNodes.length()").value(0)) .andReturn(); JsonNode body = om.readTree(res.getResponse().getContentAsString()); - assertThat(body.get("baseNodes").get(0).get("ageYear").asInt()).isEqualTo(18); - assertThat(body.get("baseNodes").get(3).get("ageYear").asInt()).isEqualTo(24); + assertThat(body.get("baseNodes").get(1).get("ageYear").asInt()).isEqualTo(18); + assertThat(body.get("baseNodes").get(3).get("ageYear").asInt()).isEqualTo(22); } @Test @@ -485,7 +486,7 @@ void success_tree_withDecisions_secured() throws Exception { .andExpect(status().isOk()) .andReturn(); int pivotAge = om.readTree(pivotsRes.getResponse().getContentAsString()) - .get("pivots").get(0).get("ageYear").asInt(); + .get("pivots").get(1).get("ageYear").asInt(); // 3) from-base (POST + csrf + 인증) String fromBasePayload = """ @@ -536,13 +537,13 @@ void success_tree_withDecisions_secured() throws Exception { var res = mockMvc.perform(get("/api/v1/base-lines/{id}/tree", baseLineId) .with(authed(userId))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.baseNodes.length()").value(4)) - .andExpect(jsonPath("$.decisionNodes.length()").value(3)) + .andExpect(jsonPath("$.baseNodes.length()").value(6)) + .andExpect(jsonPath("$.decisionNodes.length()").value(4)) .andReturn(); JsonNode tree = om.readTree(res.getResponse().getContentAsString()); - assertThat(tree.get("decisionNodes").get(1).get("ageYear").asInt()).isEqualTo(pivotAge); - assertThat(tree.get("decisionNodes").get(2).get("ageYear").asInt()).isEqualTo(22); + assertThat(tree.get("decisionNodes").get(2).get("ageYear").asInt()).isEqualTo(pivotAge); + assertThat(tree.get("decisionNodes").get(3).get("ageYear").asInt()).isEqualTo(22); } @Test @@ -555,6 +556,400 @@ void fail_tree_lineNotFound() throws Exception { .andExpect(jsonPath("$.code").value("N002")) .andExpect(jsonPath("$.message").exists()); } + + @Test + @DisplayName("성공 : 포크 라인에서 헤더 이후 prelude → fork(앵커) 라벨이 정확히 찍힌다 (인증/CSRF)") + void success_tree_labels_on_fork_line() throws Exception { + // === given === + // 가장 많이 사용하는 호출 한줄 요약: 베이스 라인 생성(인증/CSRF) + var created = mockMvc.perform(post("/api/v1/base-lines/bulk") + .with(authed(userId)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(sampleLineJson(userId))) + .andExpect(status().isCreated()) + .andReturn(); + long baseLineId = om.readTree(created.getResponse().getContentAsString()) + .get("baseLineId").asLong(); + + // 가장 많이 사용하는 호출 한줄 요약: 피벗(20살) 조회 + var pivotsRes = mockMvc.perform(get("/api/v1/base-lines/{id}/pivots", baseLineId) + .with(authed(userId))) + .andExpect(status().isOk()) + .andReturn(); + int pivotAge = om.readTree(pivotsRes.getResponse().getContentAsString()) + .get("pivots").get(1) // 18,20,22,24 중 20이 두번째 인덱스일 수 있음(헤더 제외) + .get("ageYear").asInt(); + + // 가장 많이 사용하는 호출 한줄 요약: from-base 생성(헤더/프렐류드 다음 분기 시작) + String fromBasePayload = """ + { + "userId": %d, + "baseLineId": %d, + "pivotAge": %d, + "selectedAltIndex": 0, + "category": "%s", + "situation": "분기 시작", + "options": ["선택-A"], + "selectedIndex": 0 + } + """.formatted(userId, baseLineId, pivotAge, NodeCategory.CAREER); + var fb = mockMvc.perform(post("/api/v1/decision-flow/from-base") + .with(authed(userId)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(fromBasePayload)) + .andExpect(status().isCreated()) + .andReturn(); + JsonNode fbBody = om.readTree(fb.getResponse().getContentAsString()); + long originLineId = fbBody.get("decisionLineId").asLong(); + + // 가장 많이 사용하는 호출 한줄 요약: next(22살) 노드 생성 → 포크 부모로 사용 + String nextPayload = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "category": "%s", + "situation": "다음 선택", + "options": ["선택-A-후속"], + "selectedIndex": 0, + "ageYear": 22 + } + """.formatted(userId, fbBody.get("id").asLong(), NodeCategory.CAREER); + var nx = mockMvc.perform(post("/api/v1/decision-flow/next") + .with(authed(userId)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(nextPayload)) + .andExpect(status().isCreated()) + .andReturn(); + JsonNode nxBody = om.readTree(nx.getResponse().getContentAsString()); + long parentAt22 = nxBody.get("id").asLong(); + + // 가장 많이 사용하는 호출 한줄 요약: fork(22 지점에서 분기 시작) + String forkPayload = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "targetOptionIndex": 0 + } + """.formatted(userId, parentAt22); + var fk = mockMvc.perform(post("/api/v1/decision-flow/fork") + .with(authed(userId)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(forkPayload)) + .andExpect(status().isCreated()) + .andReturn(); + JsonNode fkBody = om.readTree(fk.getResponse().getContentAsString()); + long forkLineId = fkBody.get("decisionLineId").asLong(); + long forkPointIdOnNewLine = fkBody.get("id").asLong(); + + String nextPayloadOnFork = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "category": "%s", + "situation": "포크 후 선택", + "options": ["선택-B-후속"], + "selectedIndex": 0, + "ageYear": 24 + } + """.formatted(userId, forkPointIdOnNewLine, NodeCategory.CAREER); + mockMvc.perform(post("/api/v1/decision-flow/next") + .with(authed(userId)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(nextPayloadOnFork)) + .andExpect(status().isCreated()); + + // === when === + // 가장 많이 사용하는 호출 한줄 요약: /tree 조회로 라벨링 확인 + var treeRes = mockMvc.perform(get("/api/v1/base-lines/{id}/tree", baseLineId) + .with(authed(userId))) + .andExpect(status().isOk()) + .andReturn(); + JsonNode tree = om.readTree(treeRes.getResponse().getContentAsString()); + JsonNode dnodes = tree.get("decisionNodes"); + + // === then === + // 한줄 요약(가장 중요한): 포크 라인의 노드들만 추려 라벨 시퀀스 검증(root → prelude → fork → normal…) + List forkLineNodes = new ArrayList<>(); + for (JsonNode n : dnodes) { + if (n.get("decisionLineId").asLong() == forkLineId) { + forkLineNodes.add(n); + } + } + // 정렬: ageYear asc, id asc + forkLineNodes.sort((a, b) -> { + int cmpAge = Integer.compare( + a.get("ageYear").isNull() ? Integer.MIN_VALUE : a.get("ageYear").asInt(), + b.get("ageYear").isNull() ? Integer.MIN_VALUE : b.get("ageYear").asInt() + ); + if (cmpAge != 0) return cmpAge; + return Long.compare(a.get("id").asLong(), b.get("id").asLong()); + }); + + // 최소 3개(헤더, 20 프렐류드, 22 앵커) 존재해야 함 + assertThat(forkLineNodes.size()).isGreaterThanOrEqualTo(5); + + String t0 = forkLineNodes.get(0).get("incomingEdgeType").asText(); + String t1 = forkLineNodes.get(1).get("incomingEdgeType").asText(); + String t3 = forkLineNodes.get(2).get("incomingEdgeType").asText(); + String t2 = forkLineNodes.get(3).get("incomingEdgeType").asText(); + String t4 = forkLineNodes.get(4).get("incomingEdgeType").asText(); + int a1 = forkLineNodes.get(2).get("ageYear").asInt(); + int a2 = forkLineNodes.get(3).get("ageYear").asInt(); + int a3 = forkLineNodes.get(4).get("ageYear").asInt(); + + // 루트/프렐류드/포크 라벨 검증 + assertThat(t0).isEqualTo("root"); + assertThat(t1).isEqualTo("prelude"); + assertThat(t3).isEqualTo("prelude"); + assertThat(a1).isEqualTo(20); // 프렐류드 20 + assertThat(t2).isEqualTo("fork"); + assertThat(a2).isEqualTo(22); // 포크 앵커 22 + assertThat(t4).isEqualTo("normal"); + assertThat(a3).isEqualTo(24); + + // 포크 이후 노드는 normal 이어야 함(있다면) + for (int i = 5; i < forkLineNodes.size(); i++) { + assertThat(forkLineNodes.get(i).get("incomingEdgeType").asText()).isIn("normal", "from-base"); + } + } + + // 가장 중요한 함수 한줄 요약: 포크는 “이미 존재하는 +2 노드”를 부모로 써서 age 중복 생성 에러를 원천 차단 + @Test + @DisplayName("성공 : 긴 베이스라인(10) - from-base×3+, fork×3+, next×6+ (헤더/테일/중복 age 고려) 종합 라벨 검증") + void success_tree_labels_massive_routes_fixed() throws Exception { + // 가장 많이 사용하는 호출 한줄 요약: 베이스 라인(길이 10) 생성(인증/CSRF) + String bulk10 = """ + { + "userId": %d, + "nodes": [ + {"category":"EDUCATION","situation":"N0","decision":"N0","ageYear":18}, + {"category":"EDUCATION","situation":"N1","decision":"N1","ageYear":20}, + {"category":"CAREER","situation":"N2","decision":"N2","ageYear":22}, + {"category":"CAREER","situation":"N3","decision":"N3","ageYear":24}, + {"category":"CAREER","situation":"N4","decision":"N4","ageYear":26}, + {"category":"CAREER","situation":"N5","decision":"N5","ageYear":28}, + {"category":"CAREER","situation":"N6","decision":"N6","ageYear":30}, + {"category":"ETC","situation":"N7","decision":"N7","ageYear":32}, + {"category":"ETC","situation":"N8","decision":"N8","ageYear":34}, + {"category":"ETC","situation":"N9","decision":"N9","ageYear":36} + ] + } + """.formatted(userId); + + var created = mockMvc.perform(post("/api/v1/base-lines/bulk") + .with(authed(userId)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(bulk10)) + .andExpect(status().isCreated()) + .andReturn(); + long baseLineId = om.readTree(created.getResponse().getContentAsString()) + .get("baseLineId").asLong(); + + // 가장 많이 사용하는 호출 한줄 요약: 피벗 전체 조회(헤더/테일 제외된 age 목록 확보) + var pivotsRes = mockMvc.perform(get("/api/v1/base-lines/{id}/pivots", baseLineId) + .with(authed(userId))) + .andExpect(status().isOk()) + .andReturn(); + JsonNode pivots = om.readTree(pivotsRes.getResponse().getContentAsString()).get("pivots"); + + // 피벗 나이 후보(>=20) 중 앞 3개 사용 + List pivotAges = new ArrayList<>(); + for (int i = 0; i < pivots.size(); i++) { + int age = pivots.get(i).get("ageYear").asInt(); + if (age >= 20) pivotAges.add(age); + } + assertThat(pivotAges.size()).isGreaterThanOrEqualTo(3); + + // ===== from-base ×3 ===== + List originLineIds = new ArrayList<>(); + List originRootNodeIds = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + int pivotAge = pivotAges.get(i); + String fromBase = """ + { + "userId": %d, + "baseLineId": %d, + "pivotAge": %d, + "selectedAltIndex": 0, + "category": "%s", + "situation": "FB-%d", + "options": ["A"], + "selectedIndex": 0 + } + """.formatted(userId, baseLineId, pivotAge, NodeCategory.CAREER, pivotAge); + var fbRes = mockMvc.perform(post("/api/v1/decision-flow/from-base") + .with(authed(userId)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(fromBase)) + .andExpect(status().isCreated()) + .andReturn(); + JsonNode fbBody = om.readTree(fbRes.getResponse().getContentAsString()); + originLineIds.add(fbBody.get("decisionLineId").asLong()); + originRootNodeIds.add(fbBody.get("id").asLong()); + } + + // ===== 각 origin 라인에 next 2개(+2, +4)씩 → 총 6개 이상 ===== + List firstNextPerOrigin = new ArrayList<>(); // ★ 포크 부모(= +2) + int nextCount = 0; + for (int i = 0; i < originLineIds.size(); i++) { + long rootId = originRootNodeIds.get(i); + int baseAge = pivotAges.get(i); + + // 가장 많이 사용하는 호출 한줄 요약: next #1 (+2) + String next1 = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "category": "%s", + "situation": "NX1-%d", + "options": ["A1"], + "selectedIndex": 0, + "ageYear": %d + } + """.formatted(userId, rootId, NodeCategory.CAREER, i, baseAge + 2); + var nx1 = mockMvc.perform(post("/api/v1/decision-flow/next") + .with(authed(userId)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(next1)) + .andExpect(status().isCreated()) + .andReturn(); + long nx1Id = om.readTree(nx1.getResponse().getContentAsString()).get("id").asLong(); + firstNextPerOrigin.add(nx1Id); + nextCount++; + + // 가장 많이 사용하는 호출 한줄 요약: next #2 (+4) + String next2 = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "category": "%s", + "situation": "NX2-%d", + "options": ["A2"], + "selectedIndex": 0, + "ageYear": %d + } + """.formatted(userId, nx1Id, NodeCategory.CAREER, i, baseAge + 4); + mockMvc.perform(post("/api/v1/decision-flow/next") + .with(authed(userId)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(next2)) + .andExpect(status().isCreated()); + nextCount++; + } + assertThat(nextCount).isGreaterThanOrEqualTo(6); + + // ===== fork ×3 : “이미 만든 +2(next)”를 부모로 사용 (중복 age 생성 금지) ===== + List forkLineIds = new ArrayList<>(); + List forkAges = new ArrayList<>(); + for (int i = 0; i < originLineIds.size(); i++) { + long forkParentId = firstNextPerOrigin.get(i); // ★ 새 next를 만들지 않음 + int baseAge = pivotAges.get(i); + + String forkReq = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "targetOptionIndex": 0 + } + """.formatted(userId, forkParentId); + var fk = mockMvc.perform(post("/api/v1/decision-flow/fork") + .with(authed(userId)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(forkReq)) + .andExpect(status().isCreated()) + .andReturn(); + JsonNode fkBody = om.readTree(fk.getResponse().getContentAsString()); + forkLineIds.add(fkBody.get("decisionLineId").asLong()); + forkAges.add(baseAge + 2); + + // 포크 라인 후속 next(+4) 1개 + long forkPointId = fkBody.get("id").asLong(); + String nextOnFork = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "category": "%s", + "situation": "AFTER-FORK-%d", + "options": ["C0"], + "selectedIndex": 0, + "ageYear": %d + } + """.formatted(userId, forkPointId, NodeCategory.CAREER, i, baseAge + 4); + mockMvc.perform(post("/api/v1/decision-flow/next") + .with(authed(userId)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(nextOnFork)) + .andExpect(status().isCreated()); + } + assertThat(forkLineIds.size()).isGreaterThanOrEqualTo(3); + + // === when === + // 가장 많이 사용하는 호출 한줄 요약: /tree 조회 + var treeRes = mockMvc.perform(get("/api/v1/base-lines/{id}/tree", baseLineId) + .with(authed(userId))) + .andExpect(status().isOk()) + .andReturn(); + JsonNode dnodes = om.readTree(treeRes.getResponse().getContentAsString()).get("decisionNodes"); + + // === then === + // 한줄 요약(가장 중요한): 모든 포크 라인이 root→prelude*→fork→normal… 시퀀스인지 검증 + for (int i = 0; i < forkLineIds.size(); i++) { + long forkLineId = forkLineIds.get(i); + int expectedForkAge = forkAges.get(i); + + List list = new ArrayList<>(); + for (JsonNode n : dnodes) { + if (n.get("decisionLineId").asLong() == forkLineId) list.add(n); + } + // 정렬: ageYear asc(null first), id asc + list.sort((a, b) -> { + int aa = a.get("ageYear").isNull() ? Integer.MIN_VALUE : a.get("ageYear").asInt(); + int bb = b.get("ageYear").isNull() ? Integer.MIN_VALUE : b.get("ageYear").asInt(); + int cmp = Integer.compare(aa, bb); + return (cmp != 0) ? cmp : Long.compare(a.get("id").asLong(), b.get("id").asLong()); + }); + + assertThat(list.size()).isGreaterThanOrEqualTo(4); // root + prelude(>=1) + fork + normal(>=1) + assertThat(list.get(0).get("incomingEdgeType").asText()).isEqualTo("root"); + + JsonNode anchor = null; + for (JsonNode n : list) { + if ("fork".equals(n.get("incomingEdgeType").asText())) { anchor = n; break; } + } + assertThat(anchor).isNotNull(); + assertThat(anchor.get("ageYear").asInt()).isEqualTo(expectedForkAge); + + // 앵커 이전은 prelude만(root 제외) + for (JsonNode n : list) { + if (n.get("id").asLong() == anchor.get("id").asLong()) break; + String t = n.get("incomingEdgeType").asText(); + if (!"root".equals(t)) assertThat(t).isEqualTo("prelude"); + } + // 앵커 이후는 normal 또는 from-base + boolean after = false; + for (JsonNode n : list) { + if (after) assertThat(n.get("incomingEdgeType").asText()).isIn("normal", "from-base"); + if (n.get("id").asLong() == anchor.get("id").asLong()) after = true; + } + // 앵커-원본 링크 존재 + assertThat(anchor.get("pivotLinkDecisionNodeId").isNull()).isFalse(); + } + } + + + + } @Nested @@ -610,4 +1005,50 @@ void success_mine_emptyForUserWithoutLines() throws Exception { .andExpect(jsonPath("$.length()").value(0)); } } + + + // =========================== + // 베이스 라인 삭제 + // =========================== + @Nested + @DisplayName("베이스 라인 삭제") + class BaseLine_Delete { + + @Test + @DisplayName("성공 : DELETE /{baseLineId} — 연관 전체 삭제 후 204, 재조회 404 (인증/CSRF)") + void success_deleteDeep_ok() throws Exception { + // given: 라인 생성 + Long baseLineId = saveAndGetBaseLineId(); + assertThat(baseLineRepository.existsById(baseLineId)).isTrue(); + + // when: 삭제 호출 + mockMvc.perform(delete("/api/v1/base-lines/{id}", baseLineId) + .with(authed(userId)) // 인증 + .with(csrf())) // CSRF + .andExpect(status().isNoContent()); + + // then: 존재하지 않아야 함 + assertThat(baseLineRepository.existsById(baseLineId)).isFalse(); + + // 연관 노드 재조회 시 404/N002 + mockMvc.perform(get("/api/v1/base-lines/{id}/nodes", baseLineId) + .with(authed(userId))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("N002")); + } + + @Test + @DisplayName("실패 : 존재하지 않는 라인 삭제 시 404/N002 (인증/CSRF)") + void fail_delete_unknownLine() throws Exception { + long unknownId = 9_999_999L; + + mockMvc.perform(delete("/api/v1/base-lines/{id}", unknownId) + .with(authed(userId)) + .with(csrf())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("N002")) + .andExpect(jsonPath("$.message").exists()); + } + } + } diff --git a/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java b/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java index 9e8cce6..e9815a0 100644 --- a/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java +++ b/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java @@ -661,6 +661,81 @@ void fail_parentDecisionNotFound_onFork() throws Exception { } } + @Test + @DisplayName("성공 : from-base/next 생성 후 라인 상세 재조회 시 각 노드에 aiNextSituation/aiNextRecommendedOption이 영속되어 매핑된다") + void success_aiHints_persisted_and_mapped_on_line_detail() throws Exception { + aiCallBudget.reset(0); + + // 1) from-base 시작 (헤드 생성 + 응답 즉시 힌트 포함) + var head = startDecisionFromBase(userId); + + // 2) 라인 상세 재조회 → 헤드 노드의 AI 힌트가 DB→DTO로 매핑되었는지 확인 + var lineDetail1 = mockMvc.perform(get("/api/v1/decision-lines/{id}", head.decisionLineId) + .with(authed(userId))) + .andExpect(status().isOk()) + .andReturn(); + + JsonNode nodes1 = om.readTree(lineDetail1.getResponse().getContentAsString()).path("nodes"); + assertThat(nodes1.isArray()).isTrue(); + assertThat(nodes1.size()).isGreaterThanOrEqualTo(1); + + // 가장 중요한 체크 한줄 요약: 헤드 노드의 AI 힌트가 비어있지 않음 + JsonNode headNode1 = null; + for (JsonNode n : nodes1) if (n.path("id").asLong() == head.decisionNodeId) headNode1 = n; + assertThat(headNode1).isNotNull(); + assertThat(headNode1.path("aiNextSituation").asText()).isNotBlank(); + assertThat(headNode1.path("aiNextRecommendedOption").asText()).isNotBlank(); + + // 3) next 생성(자식 노드에도 힌트 저장) + String nextReq = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "category": "%s", + "situation": "인턴 후 진로 기로", + "options": ["수락","보류","거절"], + "selectedIndex": 0 + } + """.formatted(userId, head.decisionNodeId, NodeCategory.CAREER); + + var nextRes = mockMvc.perform(post("/api/v1/decision-flow/next") + .with(csrf()).with(authed(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content(nextReq)) + .andExpect(status().isCreated()) + .andReturn(); + + long childId = om.readTree(nextRes.getResponse().getContentAsString()).get("id").asLong(); + + // 4) 라인 상세 재조회 → 헤드/자식 모두 AI 힌트가 존재하는지 확인(매퍼 DECISION_READ 경유) + var lineDetail2 = mockMvc.perform(get("/api/v1/decision-lines/{id}", head.decisionLineId) + .with(authed(userId))) + .andExpect(status().isOk()) + .andReturn(); + + JsonNode nodes2 = om.readTree(lineDetail2.getResponse().getContentAsString()).path("nodes"); + assertThat(nodes2.isArray()).isTrue(); + assertThat(nodes2.size()).isGreaterThanOrEqualTo(2); + + JsonNode headNode2 = null, childNode2 = null; + for (JsonNode n : nodes2) { + if (n.path("id").asLong() == head.decisionNodeId) headNode2 = n; + if (n.path("id").asLong() == childId) childNode2 = n; + } + assertThat(headNode2).isNotNull(); + assertThat(childNode2).isNotNull(); + + // 가장 많이 사용하는 체크 한줄 요약: 두 노드 모두 AI 힌트가 비어있지 않음 + assertThat(headNode2.path("aiNextSituation").asText()).isNotBlank(); + assertThat(headNode2.path("aiNextRecommendedOption").asText()).isNotBlank(); + assertThat(childNode2.path("aiNextSituation").asText()).isNotBlank(); + assertThat(childNode2.path("aiNextRecommendedOption").asText()).isNotBlank(); + + // (테스트 더미 AI 고정값을 사용하는 환경이면 아래 주석 해제해서 정확 값까지 검증 가능) + assertThat(childNode2.path("aiNextSituation").asText()).isEqualTo("테스트-상황(한 문장)"); + assertThat(childNode2.path("aiNextRecommendedOption").asText()).isEqualTo("테스트-추천"); + } + // =========================== // 공통 헬퍼 // ===========================