Skip to content

Commit ee71565

Browse files
authored
Merge pull request #30 from prgrms-web-devcourse-final-project/node/3
[FEAT]: 결정라인 및 트리구조 조회 구현
2 parents d872f6a + 205abf5 commit ee71565

File tree

13 files changed

+799
-79
lines changed

13 files changed

+799
-79
lines changed

back/src/main/java/com/back/domain/node/controller/BaseLineController.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
/**
22
* [API] BaseLine 전용 엔드포인트
33
* - 라인 단위 일괄 생성 / 중간 분기점(pivot) 조회
4+
* - 전체 노드 목록 조회 / 단일 노드 조회
5+
* - 사용자 전체 트리 조회 (베이스/결정 노드 일괄 반환)
46
*/
57
package com.back.domain.node.controller;
68

7-
import com.back.domain.node.dto.BaseLineBulkCreateRequest;
8-
import com.back.domain.node.dto.BaseLineBulkCreateResponse;
9-
import com.back.domain.node.dto.BaseNodeDto;
10-
import com.back.domain.node.dto.PivotListDto;
9+
import com.back.domain.node.dto.*;
1110
import com.back.domain.node.service.NodeService;
1211
import lombok.RequiredArgsConstructor;
1312
import org.springframework.http.HttpStatus;
@@ -46,4 +45,12 @@ public ResponseEntity<List<BaseNodeDto>> getBaseLineNodes(@PathVariable Long bas
4645
public ResponseEntity<BaseNodeDto> getBaseNode(@PathVariable Long baseNodeId) {
4746
return ResponseEntity.ok(nodeService.getBaseNode(baseNodeId));
4847
}
48+
49+
// 사용자 전체 트리 조회 (베이스/결정 노드 일괄 반환)
50+
@GetMapping("/{baseLineId}/tree")
51+
public ResponseEntity<TreeDto> getTreeForBaseLine(@PathVariable Long baseLineId) {
52+
// 트리 조회 서비스 호출
53+
TreeDto tree = nodeService.getTreeForBaseLine(baseLineId);
54+
return ResponseEntity.ok(tree);
55+
}
4956
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* [API] DecisionLine 조회 전용 컨트롤러
3+
* - 목록: 사용자별 라인 요약
4+
* - 상세: 라인 메타 + 노드 목록
5+
*/
6+
package com.back.domain.node.controller;
7+
8+
import com.back.domain.node.dto.DecisionLineDetailDto;
9+
import com.back.domain.node.dto.DecisionLineListDto;
10+
import com.back.domain.node.service.NodeQueryService;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.web.bind.annotation.*;
14+
15+
@RestController
16+
@RequestMapping("/api/v1/decision-lines")
17+
@RequiredArgsConstructor
18+
public class DecisionLineController {
19+
20+
private final NodeQueryService nodeQueryService;
21+
22+
// 가장 중요한: 사용자별 결정 라인 목록(요약)
23+
@GetMapping
24+
public ResponseEntity<DecisionLineListDto> list(@RequestParam Long userId) {
25+
return ResponseEntity.ok(nodeQueryService.getDecisionLines(userId));
26+
}
27+
28+
// 가장 많이 사용하는: 특정 결정 라인 상세
29+
@GetMapping("/{decisionLineId}")
30+
public ResponseEntity<DecisionLineDetailDto> detail(@PathVariable Long decisionLineId) {
31+
return ResponseEntity.ok(nodeQueryService.getDecisionLineDetail(decisionLineId));
32+
}
33+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* [DTO-RES] 결정 라인 상세(라인 메타 + 노드 목록)
3+
* - nodes는 시간축(ageYear asc)으로 정렬
4+
*/
5+
package com.back.domain.node.dto;
6+
7+
import com.back.domain.node.entity.DecisionLineStatus;
8+
import java.util.List;
9+
10+
public record DecisionLineDetailDto(
11+
Long decisionLineId,
12+
Long userId,
13+
Long baseLineId,
14+
DecisionLineStatus status,
15+
List<DecLineDto> nodes
16+
) {}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* [DTO-RES] 결정 라인 목록(요약) 응답
3+
*/
4+
package com.back.domain.node.dto;
5+
6+
import com.back.domain.node.entity.DecisionLineStatus;
7+
import java.time.LocalDateTime;
8+
import java.util.List;
9+
10+
public record DecisionLineListDto(
11+
List<LineSummary> lines
12+
) {
13+
public record LineSummary(
14+
Long decisionLineId,
15+
Long baseLineId,
16+
DecisionLineStatus status,
17+
Integer nodeCount,
18+
Integer firstAge,
19+
Integer lastAge,
20+
LocalDateTime createdAt
21+
) {}
22+
}

back/src/main/java/com/back/domain/node/dto/TreeDto.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
/**
22
* [DTO] 트리 전체 조회 응답 모델 (Base/Decision 분리 리스트)
3+
*
4+
* 흐름 요약
5+
* - baseNodes : 사용자 소유 BaseNode 전부를 ageYear asc, id asc로 정렬해 반환
6+
* - decisionNodes : 사용자 소유 모든 DecisionLine의 노드를 라인별 정렬 조회 후 평탄화하여 반환
37
*/
48
package com.back.domain.node.dto;
59

back/src/main/java/com/back/domain/node/repository/DecisionLineRepository.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@
1313
@Repository
1414
public interface DecisionLineRepository extends JpaRepository<DecisionLine, Long> {
1515
List<DecisionLine> findByUser(User user);
16+
List<DecisionLine> findByBaseLine_Id(Long baseLineId);
1617
}
Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
/**
2+
* 결정 노드 엔티티에 대한 데이터베이스 접근을 담당하는 JpaRepository.
3+
*/
14
package com.back.domain.node.repository;
25

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

7-
/**
8-
* 결정 노드 엔티티에 대한 데이터베이스 접근을 담당하는 JpaRepository.
9-
*/
10+
import java.util.List;
11+
1012
@Repository
1113
public interface DecisionNodeRepository extends JpaRepository<DecisionNode, Long> {
12-
}
14+
15+
// 라인별 노드 리스트(나이 ASC, id ASC) — 라인 상세용
16+
List<DecisionNode> findByDecisionLine_IdOrderByAgeYearAscIdAsc(Long decisionLineId);
17+
}

back/src/main/java/com/back/domain/node/service/DecisionFlowService.java

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* DecisionFlowService
3-
* - from-base: 피벗 해석 + 분기 텍스트 반영 + 선택 슬롯 링크 + 첫 결정 생성
4-
* - next : 부모 기준 다음 피벗/나이 해석 + 매칭 + 연속 결정 생성
3+
* - from-base: 피벗 해석 + 분기 텍스트 반영(1~2개; 단일 옵션은 선택 슬롯만) + 선택 슬롯 링크 + 첫 결정 생성
4+
* - next : 부모 기준 다음 피벗/나이 해석 + 매칭 + 연속 결정 생성(옵션 1~3개)
55
* - cancel/complete: 라인 상태 전이
66
*/
77
package com.back.domain.node.service;
@@ -18,7 +18,6 @@
1818
import org.springframework.stereotype.Service;
1919

2020
import java.util.List;
21-
import java.util.Objects;
2221

2322
@Service
2423
@RequiredArgsConstructor
@@ -29,35 +28,50 @@ class DecisionFlowService {
2928
private final BaseNodeRepository baseNodeRepository;
3029
private final NodeDomainSupport support;
3130

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

36+
// 피벗 해석
3737
List<BaseNode> ordered = support.getOrderedBaseNodes(request.baseLineId());
3838
int pivotAge = support.resolvePivotAge(request.pivotOrd(), request.pivotAge(),
3939
support.allowedPivotAges(ordered));
4040
BaseNode pivot = support.findBaseNodeByAge(ordered, pivotAge);
4141

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

44+
// 이미 링크된 슬롯은 시작 불가
45+
Long chosenTarget = (sel == 0) ? pivot.getAltOpt1TargetDecisionId() : pivot.getAltOpt2TargetDecisionId();
46+
if (chosenTarget != null) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "branch slot already linked");
47+
48+
// FromBase 옵션: 1~2개만 허용(단일 옵션은 선택 슬롯에만 반영)
4449
List<String> opts = request.options();
50+
Integer selectedIndex = request.selectedIndex();
4551
if (opts != null && !opts.isEmpty()) {
46-
support.validateOptions(opts, request.selectedIndex(),
47-
request.selectedIndex() != null ? opts.get(request.selectedIndex()) : null);
48-
support.ensurePivotAltTexts(pivot, opts);
52+
support.validateOptionsForFromBase(opts, selectedIndex);
53+
support.upsertPivotAltTextsForFromBase(pivot, opts, sel);
4954
baseNodeRepository.save(pivot);
5055
}
5156

52-
String chosen = (sel == 0) ? pivot.getAltOpt1() : pivot.getAltOpt2();
53-
Long chosenTarget = (sel == 0) ? pivot.getAltOpt1TargetDecisionId() : pivot.getAltOpt2TargetDecisionId();
54-
if (chosenTarget != null) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "branch slot already linked");
55-
boolean hasOptions = opts != null && !opts.isEmpty();
56-
if ((chosen == null || chosen.isBlank()) && !hasOptions)
57+
// 최종 decision 텍스트 결정
58+
String chosenNow = (sel == 0) ? pivot.getAltOpt1() : pivot.getAltOpt2();
59+
String finalDecision =
60+
(opts != null && !opts.isEmpty() && selectedIndex != null
61+
&& selectedIndex >= 0 && selectedIndex < opts.size())
62+
? opts.get(selectedIndex)
63+
: (opts != null && opts.size() == 1 ? opts.get(0) : chosenNow);
64+
65+
if (finalDecision == null || finalDecision.isBlank())
5766
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "empty branch slot and no options");
5867

68+
// 라인 생성
5969
DecisionLine line = decisionLineRepository.save(
60-
DecisionLine.builder().user(pivot.getUser()).baseLine(pivot.getBaseLine()).status(DecisionLineStatus.DRAFT).build()
70+
DecisionLine.builder()
71+
.user(pivot.getUser())
72+
.baseLine(pivot.getBaseLine())
73+
.status(DecisionLineStatus.DRAFT)
74+
.build()
6175
);
6276

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

69-
String finalDecision = (hasOptions && request.selectedIndex() != null
70-
&& request.selectedIndex() >= 0 && request.selectedIndex() < opts.size())
71-
? opts.get(request.selectedIndex())
72-
: chosen;
73-
74-
if (hasOptions && request.selectedIndex() != null && !Objects.equals(request.selectedIndex(), sel)) {
75-
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "selectedIndex must equal selectedAltIndex");
76-
}
83+
// 단일 옵션 & selectedIndex 미지정 시 0으로 기록(프론트 편의)
84+
Integer normalizedSelected = (opts != null && opts.size() == 1 && selectedIndex == null) ? 0 : selectedIndex;
7785

7886
DecisionNodeCreateRequestDto createReq = new DecisionNodeCreateRequestDto(
7987
line.getId(), null, pivot.getId(),
8088
request.category() != null ? request.category() : pivot.getCategory(),
8189
situation, finalDecision, pivotAge,
82-
opts, request.selectedIndex(), null
90+
opts, normalizedSelected, null
8391
);
8492

8593
DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq));
86-
if (sel == 0) pivot.setAltOpt1TargetDecisionId(saved.getId()); else pivot.setAltOpt2TargetDecisionId(saved.getId());
94+
95+
// 선택된 슬롯만 링크
96+
if (sel == 0) pivot.setAltOpt1TargetDecisionId(saved.getId());
97+
else pivot.setAltOpt2TargetDecisionId(saved.getId());
8798
baseNodeRepository.save(pivot);
99+
88100
return mapper.toResponse(saved);
89101
}
90102

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

108120
if (request.options() != null && !request.options().isEmpty()) {
121+
// Next는 1~3개 허용(기존 규칙)
109122
support.validateOptions(request.options(), request.selectedIndex(),
110123
request.selectedIndex() != null ? request.options().get(request.selectedIndex()) : null);
111124
}

back/src/main/java/com/back/domain/node/service/NodeDomainSupport.java

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* NodeDomainSupport (공통 헬퍼)
33
* - 입력 검증, 피벗/나이 해석, 정렬 조회, 옵션 검증, 제목 생성, 예외 매핑 등
4+
* - FromBase 전용 옵션 규칙(1~2개, 단일 옵션은 선택 슬롯만) 포함
45
*/
56
package com.back.domain.node.service;
67

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

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

97-
// 옵션 1~3, selectedIndex/decision 일관성 검증
98+
// (가장 중요한) FromBase용 옵션 검증(1~2개, 선택 인덱스 범위만 체크)
99+
public void validateOptionsForFromBase(List<String> options, Integer selectedIndex) {
100+
if (options == null || options.isEmpty())
101+
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "options required");
102+
if (options.size() > 2)
103+
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "options up to 2 on from-base");
104+
for (String s : options)
105+
if (s == null || s.isBlank())
106+
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "option text blank");
107+
if (selectedIndex != null && (selectedIndex < 0 || selectedIndex >= options.size()))
108+
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "selectedIndex out of range");
109+
}
110+
111+
// (가장 많이 사용하는) FromBase에서 피벗 슬롯 텍스트 반영(1~2개; 단일 옵션은 선택 슬롯에만 기록)
112+
public void upsertPivotAltTextsForFromBase(BaseNode pivot, List<String> options, int selectedAltIndex) {
113+
if (options == null || options.isEmpty()) return;
114+
115+
// 단일 옵션: 선택 슬롯에만 적용
116+
if (options.size() == 1) {
117+
String text = options.get(0);
118+
if (selectedAltIndex == 0) {
119+
if (pivot.getAltOpt1TargetDecisionId() != null && !text.equals(pivot.getAltOpt1()))
120+
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 already linked");
121+
if (pivot.getAltOpt1() == null || pivot.getAltOpt1().isBlank()) pivot.setAltOpt1(text);
122+
else if (!pivot.getAltOpt1().equals(text)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 text mismatch");
123+
} else {
124+
if (pivot.getAltOpt2TargetDecisionId() != null && !text.equals(pivot.getAltOpt2()))
125+
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 already linked");
126+
if (pivot.getAltOpt2() == null || pivot.getAltOpt2().isBlank()) pivot.setAltOpt2(text);
127+
else if (!pivot.getAltOpt2().equals(text)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 text mismatch");
128+
}
129+
return;
130+
}
131+
132+
// 옵션 2개: 앞 두 개를 alt1/alt2 반영(선택 슬롯 우선)
133+
String o1 = options.get(0);
134+
String o2 = options.get(1);
135+
136+
if (selectedAltIndex == 0) {
137+
if (pivot.getAltOpt1TargetDecisionId() != null && !o1.equals(pivot.getAltOpt1()))
138+
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 already linked");
139+
if (pivot.getAltOpt1() == null || pivot.getAltOpt1().isBlank()) pivot.setAltOpt1(o1);
140+
else if (!pivot.getAltOpt1().equals(o1)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 text mismatch");
141+
142+
if (pivot.getAltOpt2() == null || pivot.getAltOpt2().isBlank()) pivot.setAltOpt2(o2);
143+
else if (!pivot.getAltOpt2().equals(o2)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 text mismatch");
144+
} else {
145+
if (pivot.getAltOpt2TargetDecisionId() != null && !o2.equals(pivot.getAltOpt2()))
146+
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 already linked");
147+
if (pivot.getAltOpt2() == null || pivot.getAltOpt2().isBlank()) pivot.setAltOpt2(o2);
148+
else if (!pivot.getAltOpt2().equals(o2)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 text mismatch");
149+
150+
if (pivot.getAltOpt1() == null || pivot.getAltOpt1().isBlank()) pivot.setAltOpt1(o1);
151+
else if (!pivot.getAltOpt1().equals(o1)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 text mismatch");
152+
}
153+
}
154+
155+
// Next용 옵션 1~3, selectedIndex/decision 일관성 검증
98156
public void validateOptions(List<String> options, Integer selectedIndex, String decision) {
99157
if (options == null || options.isEmpty()) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "options required");
100158
if (options.size() > 3) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "options up to 3");
@@ -107,33 +165,6 @@ public void validateOptions(List<String> options, Integer selectedIndex, String
107165
}
108166
}
109167

110-
// 피벗 alt 슬롯 텍스트 채우기/검증
111-
public void ensurePivotAltTexts(BaseNode pivot, List<String> options) {
112-
String o1 = options.size() > 0 ? options.get(0) : null;
113-
String o2 = options.size() > 1 ? options.get(1) : null;
114-
115-
if (o1 != null && !o1.isBlank()) {
116-
if (pivot.getAltOpt1() == null || pivot.getAltOpt1().isBlank()) {
117-
pivot.setAltOpt1(o1);
118-
} else if (!pivot.getAltOpt1().equals(o1)) {
119-
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 text mismatch");
120-
}
121-
}
122-
if (o2 != null && !o2.isBlank()) {
123-
if (pivot.getAltOpt2() == null || pivot.getAltOpt2().isBlank()) {
124-
pivot.setAltOpt2(o2);
125-
} else if (!pivot.getAltOpt2().equals(o2)) {
126-
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 text mismatch");
127-
}
128-
}
129-
if (pivot.getAltOpt1TargetDecisionId() != null && o1 != null && !o1.equals(pivot.getAltOpt1())) {
130-
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt1 already linked");
131-
}
132-
if (pivot.getAltOpt2TargetDecisionId() != null && o2 != null && !o2.equals(pivot.getAltOpt2())) {
133-
throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "altOpt2 already linked");
134-
}
135-
}
136-
137168
// 기본 제목 생성: “제목없음{n}” 자동증가
138169
public String normalizeOrAutoTitle(String raw, User user) {
139170
String t = (raw == null || raw.trim().isEmpty()) ? null : raw.trim();

0 commit comments

Comments
 (0)