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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
/**
* [API] BaseLine 전용 엔드포인트
* - 라인 단위 일괄 생성 / 중간 분기점(pivot) 조회
* - 전체 노드 목록 조회 / 단일 노드 조회
* - 사용자 전체 트리 조회 (베이스/결정 노드 일괄 반환)
*/
package com.back.domain.node.controller;

import com.back.domain.node.dto.BaseLineBulkCreateRequest;
import com.back.domain.node.dto.BaseLineBulkCreateResponse;
import com.back.domain.node.dto.BaseNodeDto;
import com.back.domain.node.dto.PivotListDto;
import com.back.domain.node.dto.*;
import com.back.domain.node.service.NodeService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -46,4 +45,12 @@ public ResponseEntity<List<BaseNodeDto>> getBaseLineNodes(@PathVariable Long bas
public ResponseEntity<BaseNodeDto> getBaseNode(@PathVariable Long baseNodeId) {
return ResponseEntity.ok(nodeService.getBaseNode(baseNodeId));
}

// 사용자 전체 트리 조회 (베이스/결정 노드 일괄 반환)
@GetMapping("/{baseLineId}/tree")
public ResponseEntity<TreeDto> getTreeForBaseLine(@PathVariable Long baseLineId) {
// 트리 조회 서비스 호출
TreeDto tree = nodeService.getTreeForBaseLine(baseLineId);
return ResponseEntity.ok(tree);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* [API] DecisionLine 조회 전용 컨트롤러
* - 목록: 사용자별 라인 요약
* - 상세: 라인 메타 + 노드 목록
*/
package com.back.domain.node.controller;

import com.back.domain.node.dto.DecisionLineDetailDto;
import com.back.domain.node.dto.DecisionLineListDto;
import com.back.domain.node.service.NodeQueryService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/decision-lines")
@RequiredArgsConstructor
public class DecisionLineController {

private final NodeQueryService nodeQueryService;

// 가장 중요한: 사용자별 결정 라인 목록(요약)
@GetMapping
public ResponseEntity<DecisionLineListDto> list(@RequestParam Long userId) {
return ResponseEntity.ok(nodeQueryService.getDecisionLines(userId));
}

// 가장 많이 사용하는: 특정 결정 라인 상세
@GetMapping("/{decisionLineId}")
public ResponseEntity<DecisionLineDetailDto> detail(@PathVariable Long decisionLineId) {
return ResponseEntity.ok(nodeQueryService.getDecisionLineDetail(decisionLineId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* [DTO-RES] 결정 라인 상세(라인 메타 + 노드 목록)
* - nodes는 시간축(ageYear asc)으로 정렬
*/
package com.back.domain.node.dto;

import com.back.domain.node.entity.DecisionLineStatus;
import java.util.List;

public record DecisionLineDetailDto(
Long decisionLineId,
Long userId,
Long baseLineId,
DecisionLineStatus status,
List<DecLineDto> nodes
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* [DTO-RES] 결정 라인 목록(요약) 응답
*/
package com.back.domain.node.dto;

import com.back.domain.node.entity.DecisionLineStatus;
import java.time.LocalDateTime;
import java.util.List;

public record DecisionLineListDto(
List<LineSummary> lines
) {
public record LineSummary(
Long decisionLineId,
Long baseLineId,
DecisionLineStatus status,
Integer nodeCount,
Integer firstAge,
Integer lastAge,
LocalDateTime createdAt
) {}
}
4 changes: 4 additions & 0 deletions back/src/main/java/com/back/domain/node/dto/TreeDto.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/**
* [DTO] 트리 전체 조회 응답 모델 (Base/Decision 분리 리스트)
*
* 흐름 요약
* - baseNodes : 사용자 소유 BaseNode 전부를 ageYear asc, id asc로 정렬해 반환
* - decisionNodes : 사용자 소유 모든 DecisionLine의 노드를 라인별 정렬 조회 후 평탄화하여 반환
*/
package com.back.domain.node.dto;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@
@Repository
public interface DecisionLineRepository extends JpaRepository<DecisionLine, Long> {
List<DecisionLine> findByUser(User user);
List<DecisionLine> findByBaseLine_Id(Long baseLineId);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
/**
* 결정 노드 엔티티에 대한 데이터베이스 접근을 담당하는 JpaRepository.
*/
package com.back.domain.node.repository;

import com.back.domain.node.entity.DecisionNode;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/**
* 결정 노드 엔티티에 대한 데이터베이스 접근을 담당하는 JpaRepository.
*/
import java.util.List;

@Repository
public interface DecisionNodeRepository extends JpaRepository<DecisionNode, Long> {
}

// 라인별 노드 리스트(나이 ASC, id ASC) — 라인 상세용
List<DecisionNode> findByDecisionLine_IdOrderByAgeYearAscIdAsc(Long decisionLineId);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* DecisionFlowService
* - from-base: 피벗 해석 + 분기 텍스트 반영 + 선택 슬롯 링크 + 첫 결정 생성
* - next : 부모 기준 다음 피벗/나이 해석 + 매칭 + 연속 결정 생성
* - from-base: 피벗 해석 + 분기 텍스트 반영(1~2개; 단일 옵션은 선택 슬롯만) + 선택 슬롯 링크 + 첫 결정 생성
* - next : 부모 기준 다음 피벗/나이 해석 + 매칭 + 연속 결정 생성(옵션 1~3개)
* - cancel/complete: 라인 상태 전이
*/
package com.back.domain.node.service;
Expand All @@ -18,7 +18,6 @@
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Objects;

@Service
@RequiredArgsConstructor
Expand All @@ -29,35 +28,50 @@ class DecisionFlowService {
private final BaseNodeRepository baseNodeRepository;
private final NodeDomainSupport support;

// 가장 중요한: from-base 서버 해석(피벗 슬롯 텍스트 입력 허용 + 선택 슬롯만 링크)
// 가장 중요한: from-base 서버 해석(옵션 1~2개 허용; 단일 옵션은 선택 슬롯에만 반영)
public DecLineDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request) {
if (request == null || request.baseLineId() == null)
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "baseLineId is required");

// 피벗 해석
List<BaseNode> ordered = support.getOrderedBaseNodes(request.baseLineId());
int pivotAge = support.resolvePivotAge(request.pivotOrd(), request.pivotAge(),
support.allowedPivotAges(ordered));
BaseNode pivot = support.findBaseNodeByAge(ordered, pivotAge);

int sel = support.requireAltIndex(request.selectedAltIndex());

// 이미 링크된 슬롯은 시작 불가
Long chosenTarget = (sel == 0) ? pivot.getAltOpt1TargetDecisionId() : pivot.getAltOpt2TargetDecisionId();
if (chosenTarget != null) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "branch slot already linked");

// FromBase 옵션: 1~2개만 허용(단일 옵션은 선택 슬롯에만 반영)
List<String> opts = request.options();
Integer selectedIndex = request.selectedIndex();
if (opts != null && !opts.isEmpty()) {
support.validateOptions(opts, request.selectedIndex(),
request.selectedIndex() != null ? opts.get(request.selectedIndex()) : null);
support.ensurePivotAltTexts(pivot, opts);
support.validateOptionsForFromBase(opts, selectedIndex);
support.upsertPivotAltTextsForFromBase(pivot, opts, sel);
baseNodeRepository.save(pivot);
}

String chosen = (sel == 0) ? pivot.getAltOpt1() : pivot.getAltOpt2();
Long chosenTarget = (sel == 0) ? pivot.getAltOpt1TargetDecisionId() : pivot.getAltOpt2TargetDecisionId();
if (chosenTarget != null) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "branch slot already linked");
boolean hasOptions = opts != null && !opts.isEmpty();
if ((chosen == null || chosen.isBlank()) && !hasOptions)
// 최종 decision 텍스트 결정
String chosenNow = (sel == 0) ? pivot.getAltOpt1() : pivot.getAltOpt2();
String finalDecision =
(opts != null && !opts.isEmpty() && selectedIndex != null
&& selectedIndex >= 0 && selectedIndex < opts.size())
? opts.get(selectedIndex)
: (opts != null && opts.size() == 1 ? opts.get(0) : chosenNow);

if (finalDecision == null || finalDecision.isBlank())
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "empty branch slot and no options");

// 라인 생성
DecisionLine line = decisionLineRepository.save(
DecisionLine.builder().user(pivot.getUser()).baseLine(pivot.getBaseLine()).status(DecisionLineStatus.DRAFT).build()
DecisionLine.builder()
.user(pivot.getUser())
.baseLine(pivot.getBaseLine())
.status(DecisionLineStatus.DRAFT)
.build()
);

String situation = (request.situation() != null) ? request.situation() : pivot.getSituation();
Expand All @@ -66,25 +80,23 @@ public DecLineDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request
NodeMappers.DecisionNodeCtxMapper mapper =
new NodeMappers.DecisionNodeCtxMapper(pivot.getUser(), line, null, pivot, background);

String finalDecision = (hasOptions && request.selectedIndex() != null
&& request.selectedIndex() >= 0 && request.selectedIndex() < opts.size())
? opts.get(request.selectedIndex())
: chosen;

if (hasOptions && request.selectedIndex() != null && !Objects.equals(request.selectedIndex(), sel)) {
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "selectedIndex must equal selectedAltIndex");
}
// 단일 옵션 & selectedIndex 미지정 시 0으로 기록(프론트 편의)
Integer normalizedSelected = (opts != null && opts.size() == 1 && selectedIndex == null) ? 0 : selectedIndex;

DecisionNodeCreateRequestDto createReq = new DecisionNodeCreateRequestDto(
line.getId(), null, pivot.getId(),
request.category() != null ? request.category() : pivot.getCategory(),
situation, finalDecision, pivotAge,
opts, request.selectedIndex(), null
opts, normalizedSelected, null
);

DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq));
if (sel == 0) pivot.setAltOpt1TargetDecisionId(saved.getId()); else pivot.setAltOpt2TargetDecisionId(saved.getId());

// 선택된 슬롯만 링크
if (sel == 0) pivot.setAltOpt1TargetDecisionId(saved.getId());
else pivot.setAltOpt2TargetDecisionId(saved.getId());
baseNodeRepository.save(pivot);

return mapper.toResponse(saved);
}

Expand All @@ -106,6 +118,7 @@ public DecLineDto createDecisionNodeNext(DecisionNodeNextRequest request) {
BaseNode matchedBase = support.findBaseNodeByAge(ordered, nextAge);

if (request.options() != null && !request.options().isEmpty()) {
// Next는 1~3개 허용(기존 규칙)
support.validateOptions(request.options(), request.selectedIndex(),
request.selectedIndex() != null ? request.options().get(request.selectedIndex()) : null);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* NodeDomainSupport (공통 헬퍼)
* - 입력 검증, 피벗/나이 해석, 정렬 조회, 옵션 검증, 제목 생성, 예외 매핑 등
* - FromBase 전용 옵션 규칙(1~2개, 단일 옵션은 선택 슬롯만) 포함
*/
package com.back.domain.node.service;

Expand Down Expand Up @@ -35,7 +36,7 @@ public void ensureBaseLineExists(Long baseLineId) {
.orElseThrow(() -> new ApiException(ErrorCode.BASE_LINE_NOT_FOUND, "BaseLine not found: " + baseLineId));
}

// BaseLine 정렬된 노드 조회 + 라인 존재 확인
// (가장 많이 사용하는) BaseLine 정렬된 노드 조회 + 라인 존재 확인
public List<BaseNode> getOrderedBaseNodes(Long baseLineId) {
BaseLine baseLine = baseLineRepository.findById(baseLineId)
.orElseThrow(() -> new ApiException(ErrorCode.BASE_LINE_NOT_FOUND, "BaseLine not found: " + baseLineId));
Expand Down Expand Up @@ -94,7 +95,64 @@ public int requireAltIndex(Integer idx) {
return idx;
}

// 옵션 1~3, selectedIndex/decision 일관성 검증
// (가장 중요한) FromBase용 옵션 검증(1~2개, 선택 인덱스 범위만 체크)
public void validateOptionsForFromBase(List<String> options, Integer selectedIndex) {
if (options == null || options.isEmpty())
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "options required");
if (options.size() > 2)
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "options up to 2 on from-base");
for (String s : options)
if (s == null || s.isBlank())
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "option text blank");
if (selectedIndex != null && (selectedIndex < 0 || selectedIndex >= options.size()))
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "selectedIndex out of range");
}

// (가장 많이 사용하는) FromBase에서 피벗 슬롯 텍스트 반영(1~2개; 단일 옵션은 선택 슬롯에만 기록)
public void upsertPivotAltTextsForFromBase(BaseNode pivot, List<String> options, int selectedAltIndex) {
if (options == null || options.isEmpty()) return;

// 단일 옵션: 선택 슬롯에만 적용
if (options.size() == 1) {
String text = options.get(0);
if (selectedAltIndex == 0) {
if (pivot.getAltOpt1TargetDecisionId() != null && !text.equals(pivot.getAltOpt1()))
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 already linked");
if (pivot.getAltOpt1() == null || pivot.getAltOpt1().isBlank()) pivot.setAltOpt1(text);
else if (!pivot.getAltOpt1().equals(text)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 text mismatch");
} else {
if (pivot.getAltOpt2TargetDecisionId() != null && !text.equals(pivot.getAltOpt2()))
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 already linked");
if (pivot.getAltOpt2() == null || pivot.getAltOpt2().isBlank()) pivot.setAltOpt2(text);
else if (!pivot.getAltOpt2().equals(text)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 text mismatch");
}
return;
}

// 옵션 2개: 앞 두 개를 alt1/alt2 반영(선택 슬롯 우선)
String o1 = options.get(0);
String o2 = options.get(1);

if (selectedAltIndex == 0) {
if (pivot.getAltOpt1TargetDecisionId() != null && !o1.equals(pivot.getAltOpt1()))
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 already linked");
if (pivot.getAltOpt1() == null || pivot.getAltOpt1().isBlank()) pivot.setAltOpt1(o1);
else if (!pivot.getAltOpt1().equals(o1)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 text mismatch");

if (pivot.getAltOpt2() == null || pivot.getAltOpt2().isBlank()) pivot.setAltOpt2(o2);
else if (!pivot.getAltOpt2().equals(o2)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 text mismatch");
} else {
if (pivot.getAltOpt2TargetDecisionId() != null && !o2.equals(pivot.getAltOpt2()))
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 already linked");
if (pivot.getAltOpt2() == null || pivot.getAltOpt2().isBlank()) pivot.setAltOpt2(o2);
else if (!pivot.getAltOpt2().equals(o2)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 text mismatch");

if (pivot.getAltOpt1() == null || pivot.getAltOpt1().isBlank()) pivot.setAltOpt1(o1);
else if (!pivot.getAltOpt1().equals(o1)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 text mismatch");
}
}

// Next용 옵션 1~3, selectedIndex/decision 일관성 검증
public void validateOptions(List<String> options, Integer selectedIndex, String decision) {
if (options == null || options.isEmpty()) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "options required");
if (options.size() > 3) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "options up to 3");
Expand All @@ -107,33 +165,6 @@ public void validateOptions(List<String> options, Integer selectedIndex, String
}
}

// 피벗 alt 슬롯 텍스트 채우기/검증
public void ensurePivotAltTexts(BaseNode pivot, List<String> options) {
String o1 = options.size() > 0 ? options.get(0) : null;
String o2 = options.size() > 1 ? options.get(1) : null;

if (o1 != null && !o1.isBlank()) {
if (pivot.getAltOpt1() == null || pivot.getAltOpt1().isBlank()) {
pivot.setAltOpt1(o1);
} else if (!pivot.getAltOpt1().equals(o1)) {
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 text mismatch");
}
}
if (o2 != null && !o2.isBlank()) {
if (pivot.getAltOpt2() == null || pivot.getAltOpt2().isBlank()) {
pivot.setAltOpt2(o2);
} else if (!pivot.getAltOpt2().equals(o2)) {
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 text mismatch");
}
}
if (pivot.getAltOpt1TargetDecisionId() != null && o1 != null && !o1.equals(pivot.getAltOpt1())) {
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 already linked");
}
if (pivot.getAltOpt2TargetDecisionId() != null && o2 != null && !o2.equals(pivot.getAltOpt2())) {
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 already linked");
}
}

// 기본 제목 생성: “제목없음{n}” 자동증가
public String normalizeOrAutoTitle(String raw, User user) {
String t = (raw == null || raw.trim().isEmpty()) ? null : raw.trim();
Expand Down
Loading