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 d77bccb..d94bbc8 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 @@ -6,7 +6,7 @@ import com.back.domain.node.dto.BaseLineBulkCreateRequest; import com.back.domain.node.dto.BaseLineBulkCreateResponse; -import com.back.domain.node.dto.BaseLineDto; +import com.back.domain.node.dto.BaseNodeDto; import com.back.domain.node.dto.PivotListDto; import com.back.domain.node.service.NodeService; import lombok.RequiredArgsConstructor; @@ -35,16 +35,15 @@ public ResponseEntity getPivots(@PathVariable Long baseLineId) { return ResponseEntity.ok(nodeService.getPivotBaseNodes(baseLineId)); } - // GET /api/v1/base-lines/{baseLineId}/nodes + // 전체 노드 목록 반환 @GetMapping("/{baseLineId}/nodes") - public ResponseEntity> getBaseLineNodes(@PathVariable Long baseLineId) { + public ResponseEntity> getBaseLineNodes(@PathVariable Long baseLineId) { return ResponseEntity.ok(nodeService.getBaseLineNodes(baseLineId)); } - // GET /api/v1/base-lines/node/{baseNodeId} - @GetMapping("/node/{baseNodeId}") - public ResponseEntity getBaseNode(@PathVariable Long baseNodeId) { + // 단일 노드 반환 + @GetMapping("/nodes/{baseNodeId}") + public ResponseEntity getBaseNode(@PathVariable Long baseNodeId) { return ResponseEntity.ok(nodeService.getBaseNode(baseNodeId)); } - } diff --git a/back/src/main/java/com/back/domain/node/controller/DecisionFlowController.java b/back/src/main/java/com/back/domain/node/controller/DecisionFlowController.java index 05e156c..ccbf654 100644 --- a/back/src/main/java/com/back/domain/node/controller/DecisionFlowController.java +++ b/back/src/main/java/com/back/domain/node/controller/DecisionFlowController.java @@ -1,13 +1,16 @@ /** * [API] 피벗에서 시작→연속 저장→완료/취소 흐름 - * - from-base: 첫 결정 생성 - * - next: 다음 결정 생성 + * - from-base: 첫 결정 생성(서버가 피벗 노드 해석) + * - next: 다음 결정 생성(라인은 부모에서 해석) * - cancel: 라인 취소(파기) * - complete: 라인 완료(잠금) */ package com.back.domain.node.controller; -import com.back.domain.node.dto.*; +import com.back.domain.node.dto.DecLineDto; +import com.back.domain.node.dto.DecisionLineLifecycleDto; +import com.back.domain.node.dto.DecisionNodeFromBaseRequest; +import com.back.domain.node.dto.DecisionNodeNextRequest; import com.back.domain.node.service.NodeService; import lombok.RequiredArgsConstructor; import org.springframework.http.*; @@ -20,21 +23,25 @@ public class DecisionFlowController { private final NodeService nodeService; + // from-base: 라인+피벗(순번/나이)+분기슬롯 인덱스만 받아 서버가 pivotBaseNodeId를 해석 @PostMapping("/from-base") public ResponseEntity createFromBase(@RequestBody DecisionNodeFromBaseRequest request) { return ResponseEntity.status(HttpStatus.CREATED).body(nodeService.createDecisionNodeFromBase(request)); } + // next: 부모 결정 id만 받아 서버가 라인/다음 나이/베이스 매칭을 해석 @PostMapping("/next") public ResponseEntity createNext(@RequestBody DecisionNodeNextRequest request) { return ResponseEntity.status(HttpStatus.CREATED).body(nodeService.createDecisionNodeNext(request)); } + // 라인 취소 @PostMapping("/{decisionLineId}/cancel") public ResponseEntity cancel(@PathVariable Long decisionLineId) { return ResponseEntity.ok(nodeService.cancelDecisionLine(decisionLineId)); } + // 라인 완료 @PostMapping("/{decisionLineId}/complete") public ResponseEntity complete(@PathVariable Long decisionLineId) { return ResponseEntity.ok(nodeService.completeDecisionLine(decisionLineId)); diff --git a/back/src/main/java/com/back/domain/node/controller/DecisionFromBaseController.java b/back/src/main/java/com/back/domain/node/controller/DecisionFromBaseController.java deleted file mode 100644 index c353ff6..0000000 --- a/back/src/main/java/com/back/domain/node/controller/DecisionFromBaseController.java +++ /dev/null @@ -1,26 +0,0 @@ -/** - * [API] BaseLine의 pivot에서 DecisionNode 생성 - * - 사용자가 선택한 중간 시점(BaseNode)에서 파생 - */ -package com.back.domain.node.controller; - -import com.back.domain.node.dto.DecLineDto; -import com.back.domain.node.dto.DecisionNodeFromBaseRequest; -import com.back.domain.node.service.NodeService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.*; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/v1/decision-nodes") -@RequiredArgsConstructor -public class DecisionFromBaseController { - - private final NodeService nodeService; - - // pivot(BaseNode)에서 DecisionNode 생성 - @PostMapping("/from-base") - public ResponseEntity createDecisionFromBase(@RequestBody DecisionNodeFromBaseRequest request) { - return ResponseEntity.status(HttpStatus.CREATED).body(nodeService.createDecisionNodeFromBase(request)); - } -} diff --git a/back/src/main/java/com/back/domain/node/dto/BaseLineBulkCreateRequest.java b/back/src/main/java/com/back/domain/node/dto/BaseLineBulkCreateRequest.java index 34bb808..a4f6a11 100644 --- a/back/src/main/java/com/back/domain/node/dto/BaseLineBulkCreateRequest.java +++ b/back/src/main/java/com/back/domain/node/dto/BaseLineBulkCreateRequest.java @@ -9,6 +9,7 @@ public record BaseLineBulkCreateRequest( Long userId, + String title, List nodes ) { public record BaseNodePayload( diff --git a/back/src/main/java/com/back/domain/node/dto/BaseLineDto.java b/back/src/main/java/com/back/domain/node/dto/BaseLineDto.java deleted file mode 100644 index 07c1548..0000000 --- a/back/src/main/java/com/back/domain/node/dto/BaseLineDto.java +++ /dev/null @@ -1,21 +0,0 @@ -/** - * [DTO-RES] BaseNode 응답 - * - 프론트 전송 전용: 최소 필드만 노출 - */ -package com.back.domain.node.dto; - -import com.back.domain.node.entity.NodeCategory; - -public record BaseLineDto( - Long id, - Long userId, - String type, // "BASE" - NodeCategory category, - String situation, - String decision, - Integer ageYear, - Long baseLineId, - Long parentId -) { - -} diff --git a/back/src/main/java/com/back/domain/node/dto/BaseNodeDto.java b/back/src/main/java/com/back/domain/node/dto/BaseNodeDto.java new file mode 100644 index 0000000..0244d20 --- /dev/null +++ b/back/src/main/java/com/back/domain/node/dto/BaseNodeDto.java @@ -0,0 +1,25 @@ +/** + * [DTO-RES] BaseNode 응답 + * - 고정 선택과 분기 2칸 및 각 타겟 링크를 포함한다 + */ +package com.back.domain.node.dto; + +import com.back.domain.node.entity.NodeCategory; + +public record BaseNodeDto( + Long id, + Long userId, + String type, + NodeCategory category, + String situation, + String decision, + Integer ageYear, + Long baseLineId, + Long parentId, + String title, + String fixedChoice, + String altOpt1, + String altOpt2, + Long altOpt1TargetDecisionId, + Long altOpt2TargetDecisionId +) {} diff --git a/back/src/main/java/com/back/domain/node/dto/DecLineDto.java b/back/src/main/java/com/back/domain/node/dto/DecLineDto.java index d6d4de4..53f7d72 100644 --- a/back/src/main/java/com/back/domain/node/dto/DecLineDto.java +++ b/back/src/main/java/com/back/domain/node/dto/DecLineDto.java @@ -1,15 +1,16 @@ /** * [DTO-RES] DecisionNode 응답 - * - background: AI 생성 설명(옵션) + * - options/selectedIndex/parentOptionIndex를 포함해 프론트 렌더 정보를 제공한다 */ package com.back.domain.node.dto; import com.back.domain.node.entity.NodeCategory; +import java.util.List; public record DecLineDto( Long id, Long userId, - String type, // "DECISION" + String type, NodeCategory category, String situation, String decision, @@ -17,7 +18,8 @@ public record DecLineDto( Long decisionLineId, Long parentId, Long baseNodeId, - String background -) { - -} + String background, + List options, + Integer selectedIndex, + Integer parentOptionIndex +) {} diff --git a/back/src/main/java/com/back/domain/node/dto/DecisionNodeCreateRequestDto.java b/back/src/main/java/com/back/domain/node/dto/DecisionNodeCreateRequestDto.java index 93f9393..3df3d35 100644 --- a/back/src/main/java/com/back/domain/node/dto/DecisionNodeCreateRequestDto.java +++ b/back/src/main/java/com/back/domain/node/dto/DecisionNodeCreateRequestDto.java @@ -1,11 +1,11 @@ /** * [DTO-REQ] DecisionNode 생성 요청 - * - decisionLineId 없으면 새 라인 생성 - * - parentId 또는 baseNodeId 중 하나 사용 + * - 서비스 내부 매퍼용으로 사용되며 외부 API에서는 직접 받지 않는다 */ package com.back.domain.node.dto; import com.back.domain.node.entity.NodeCategory; +import java.util.List; public record DecisionNodeCreateRequestDto( Long decisionLineId, @@ -14,5 +14,8 @@ public record DecisionNodeCreateRequestDto( NodeCategory category, String situation, String decision, - Integer ageYear + Integer ageYear, + List options, + Integer selectedIndex, + Integer parentOptionIndex ) {} diff --git a/back/src/main/java/com/back/domain/node/dto/DecisionNodeFromBaseRequest.java b/back/src/main/java/com/back/domain/node/dto/DecisionNodeFromBaseRequest.java index a40461c..6e5c165 100644 --- a/back/src/main/java/com/back/domain/node/dto/DecisionNodeFromBaseRequest.java +++ b/back/src/main/java/com/back/domain/node/dto/DecisionNodeFromBaseRequest.java @@ -1,17 +1,20 @@ /** - * [DTO-REQ] BaseLine의 특정 분기점(BaseNode)에서 DecisionNode 생성 요청 - * - category/situation/ageYear 미지정 시 pivot 값 상속 + * [DTO-REQ] BaseLine 피벗에서 첫 결정 생성 요청(슬림 계약 + 옵션 지원) + * - 서버가 baseLineId + (pivotOrd | pivotAge)로 피벗 노드를 해석하고, selectedAltIndex(0/1) 분기 슬롯을 링크한다 */ package com.back.domain.node.dto; import com.back.domain.node.entity.NodeCategory; +import java.util.List; public record DecisionNodeFromBaseRequest( Long userId, Long baseLineId, - Long pivotBaseNodeId, - NodeCategory category, - String situation, - String decision, - Integer ageYear + Integer pivotOrd, // 피벗 순번(중간 노드 기준, 0부터) — null이면 pivotAge 사용 + Integer pivotAge, // 피벗 나이 — null이면 pivotOrd 사용 + Integer selectedAltIndex, // 0 또는 1 + NodeCategory category, // 미지정 시 pivot.category 상속 + String situation, // 미지정 시 pivot.situation 상속 + List options, // 1~3개, null 가능(첫 결정 노드도 옵션 보유 가능) + Integer selectedIndex // 0..2, null 가능(주어지면 decision과 일치) ) {} diff --git a/back/src/main/java/com/back/domain/node/dto/DecisionNodeNextRequest.java b/back/src/main/java/com/back/domain/node/dto/DecisionNodeNextRequest.java index e849a1c..cc1d413 100644 --- a/back/src/main/java/com/back/domain/node/dto/DecisionNodeNextRequest.java +++ b/back/src/main/java/com/back/domain/node/dto/DecisionNodeNextRequest.java @@ -1,17 +1,19 @@ /** - * [DTO-REQ] 직전 DecisionNode(parent)에서 다음 DecisionNode 생성 - * - ageYear 미지정 시 자동으로 다음 피벗 나이 선택 + * [DTO-REQ] 직전 DecisionNode(parent)에서 다음 DecisionNode 생성(슬림 계약) + * - 라인은 부모로부터 해석하고, ageYear 미지정 시 다음 피벗 나이 자동 선택 */ package com.back.domain.node.dto; import com.back.domain.node.entity.NodeCategory; +import java.util.List; public record DecisionNodeNextRequest( Long userId, Long parentDecisionNodeId, - Long decisionLineId, - NodeCategory category, - String situation, - String decision, - Integer ageYear + NodeCategory category, // 미지정 시 parent.category 상속 + String situation, // 미지정 시 parent.situation 상속 + Integer ageYear, // null이면 다음 피벗 자동 선택 + List options, // 1~3개, null 가능 + Integer selectedIndex, // 0..2, null 가능 + Integer parentOptionIndex // 부모 옵션 인덱스(0..2), null 가능 ) {} diff --git a/back/src/main/java/com/back/domain/node/dto/TreeDto.java b/back/src/main/java/com/back/domain/node/dto/TreeDto.java index cb8a302..320522a 100644 --- a/back/src/main/java/com/back/domain/node/dto/TreeDto.java +++ b/back/src/main/java/com/back/domain/node/dto/TreeDto.java @@ -6,6 +6,6 @@ import java.util.List; public record TreeDto( - List baseNodes, + List baseNodes, List decisionNodes ) {} 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 53c1018..bc6ca8d 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 @@ -26,6 +26,9 @@ public class BaseLine extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; + @Column(length = 100, nullable = false) + private String title; + // BaseLine ←→ BaseNode 양방향 매핑 (BaseNode 쪽에 @ManyToOne BaseLine baseLine 있어야 함) @OneToMany(mappedBy = "baseLine", cascade = CascadeType.ALL, orphanRemoval = false) @Builder.Default diff --git a/back/src/main/java/com/back/domain/node/entity/BaseNode.java b/back/src/main/java/com/back/domain/node/entity/BaseNode.java index 19cdfe5..e34a0b1 100644 --- a/back/src/main/java/com/back/domain/node/entity/BaseNode.java +++ b/back/src/main/java/com/back/domain/node/entity/BaseNode.java @@ -49,14 +49,31 @@ public class BaseNode extends BaseEntity { private int ageYear; - // com.back.domain.node.entity.BaseNode 안에 추가 + @Column(length = 255) + private String fixedChoice; // 고정 선택 1개 + @Column(length = 255) + private String altOpt1; // 분기 선택지 1 + + @Column(length = 255) + private String altOpt2; // 분기 선택지 2 + + private Long altOpt1TargetDecisionId; // 분기1 연결 대상 결정노드 id + + private Long altOpt2TargetDecisionId; // 분기2 연결 대상 결정노드 id + + // 헤더 판단 헬퍼 public boolean isHeaderOf(BaseLine baseLine) { if (baseLine == null) return false; - // header 판단 기준이 따로 있다면 맞게 수정 (여기선 parent == null 가정) return this.getBaseLine() != null && Objects.equals(this.getBaseLine().getId(), baseLine.getId()) && this.getParent() == null; } + // 베이스 분기 규칙 검증 + public void guardBaseOptionsValid() { + if (fixedChoice == null || fixedChoice.isBlank()) throw new IllegalArgumentException("fixedChoice required"); + if (altOpt1 != null && altOpt1.isBlank()) throw new IllegalArgumentException("altOpt1 blank"); + if (altOpt2 != null && altOpt2.isBlank()) throw new IllegalArgumentException("altOpt2 blank"); + } } 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 8901efc..72cb854 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 @@ -54,11 +54,25 @@ public class DecisionNode extends BaseEntity { @Column(columnDefinition = "TEXT") private String decision; - private int ageYear; // ← 나이(정수) + private int ageYear; @Column(columnDefinition = "TEXT") private String background; + @Column(length = 255) + private String option1; // 선택지1 + + @Column(length = 255) + private String option2; // 선택지2 + + @Column(length = 255) + private String option3; // 선택지3 + + private Integer selectedIndex; // 0..2 + + private Integer parentOptionIndex; // 부모 결정의 어떤 옵션(0..2)에서 파생됐는지 + + // 다음 나이 검증 public void guardNextAgeValid(int nextAge) { if (nextAge <= this.getAgeYear()) { throw new IllegalArgumentException("ageYear must be greater than parent's ageYear"); 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 68d4810..9d2ffa1 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 @@ -13,16 +13,17 @@ import com.back.domain.node.entity.*; import com.back.domain.user.entity.User; +import java.util.ArrayList; +import java.util.List; + public final class NodeMappers { private NodeMappers() {} - // ==== 읽기 전역 매퍼: Entity → DTO (함수형) ==== - // BaseNode → BaseLineDto - public static final Mapper BASE_READ = e -> { + public static final Mapper BASE_READ = e -> { if (e == null) throw new MappingException("BaseNode is null"); - return new BaseLineDto( + return new BaseNodeDto( e.getId(), e.getUser() != null ? e.getUser().getId() : null, e.getNodeKind() != null ? e.getNodeKind().name() : NodeType.BASE.name(), @@ -31,13 +32,23 @@ private NodeMappers() {} e.getDecision(), e.getAgeYear(), e.getBaseLine() != null ? e.getBaseLine().getId() : null, - e.getParent() != null ? e.getParent().getId() : null + e.getParent() != null ? e.getParent().getId() : null, + e.getBaseLine() != null ? e.getBaseLine().getTitle() : null, + e.getFixedChoice(), + e.getAltOpt1(), + e.getAltOpt2(), + e.getAltOpt1TargetDecisionId(), + e.getAltOpt2TargetDecisionId() ); }; // DecisionNode → DecLineDto public static final Mapper DECISION_READ = e -> { if (e == null) throw new MappingException("DecisionNode is null"); + List opts = new ArrayList<>(3); + if (e.getOption1() != null) opts.add(e.getOption1()); + if (e.getOption2() != null) opts.add(e.getOption2()); + if (e.getOption3() != null) opts.add(e.getOption3()); return new DecLineDto( e.getId(), e.getUser() != null ? e.getUser().getId() : null, @@ -49,13 +60,14 @@ private NodeMappers() {} e.getDecisionLine() != null ? e.getDecisionLine().getId() : null, e.getParent() != null ? e.getParent().getId() : null, e.getBaseNode() != null ? e.getBaseNode().getId() : null, - e.getBackground() + e.getBackground(), + opts.isEmpty() ? null : List.copyOf(opts), + e.getSelectedIndex(), + e.getParentOptionIndex() ); }; - // ==== 쓰기 매퍼(컨텍스트 보유): RequestDTO ↔ Entity ↔ ResponseDTO ==== - - public static final class BaseNodeCtxMapper implements TwoWayMapper { + public static final class BaseNodeCtxMapper implements TwoWayMapper { private final User user; private final BaseLine baseLine; private final BaseNode parent; @@ -64,32 +76,35 @@ public BaseNodeCtxMapper(User user, BaseLine baseLine, BaseNode parent) { this.user = user; this.baseLine = baseLine; this.parent = parent; } - // BaseNode 단건 생성: BaseNodeCreateRequestDto → BaseNode + // BaseNode 단건 생성 @Override public BaseNode toEntity(BaseNodeCreateRequestDto req) { if (req == null) throw new MappingException("BaseNodeCreateRequestDto is null"); - return BaseNode.builder() + BaseNode entity = BaseNode.builder() .user(user).baseLine(baseLine).parent(parent) .nodeKind(req.nodeKind() != null ? req.nodeKind() : NodeType.BASE) .category(req.category()).situation(req.situation()) .ageYear(req.ageYear() != null ? req.ageYear() : 0) .build(); + return entity; } - // BaseLine 일괄 생성: BaseLineBulkCreateRequest.BaseNodePayload → BaseNode (decision 포함) + // BaseLine 일괄 생성: Payload → BaseNode (decision→fixedChoice로 흡수) public BaseNode toEntity(BaseLineBulkCreateRequest.BaseNodePayload p) { if (p == null) throw new MappingException("BaseLineBulkCreateRequest.BaseNodePayload is null"); - return BaseNode.builder() + BaseNode entity = BaseNode.builder() .user(user).baseLine(baseLine).parent(parent) .nodeKind(NodeType.BASE) .category(p.category()).situation(p.situation()).decision(p.decision()) .ageYear(p.ageYear() != null ? p.ageYear() : 0) + .fixedChoice(p.decision()) // 고정 선택은 기존 decision을 재사용 .build(); + return entity; } // BaseNode → BaseLineDto @Override - public BaseLineDto toResponse(BaseNode entity) { + public BaseNodeDto toResponse(BaseNode entity) { return BASE_READ.map(entity); } } @@ -107,17 +122,29 @@ public DecisionNodeCtxMapper(User user, DecisionLine decisionLine, DecisionNode this.baseNode = baseNode; this.background = background; } - // DecisionNode 생성: DecisionNodeCreateRequestDto → DecisionNode + // DecisionNode 생성 @Override public DecisionNode toEntity(DecisionNodeCreateRequestDto req) { if (req == null) throw new MappingException("DecisionNodeCreateRequestDto is null"); - return DecisionNode.builder() + DecisionNode d = DecisionNode.builder() .user(user).nodeKind(NodeType.DECISION) .decisionLine(decisionLine).parent(parentDecision).baseNode(baseNode) .category(req.category()).situation(req.situation()).decision(req.decision()) .ageYear(req.ageYear() != null ? req.ageYear() : 0) .background(background) + .parentOptionIndex(req.parentOptionIndex()) .build(); + List opts = req.options(); + if (opts != null && !opts.isEmpty()) { + d.setOption1(opts.size() > 0 ? opts.get(0) : null); + d.setOption2(opts.size() > 1 ? opts.get(1) : null); + d.setOption3(opts.size() > 2 ? opts.get(2) : null); + d.setSelectedIndex(req.selectedIndex()); + if (req.selectedIndex() != null && req.selectedIndex() >= 0 && req.selectedIndex() < opts.size()) { + if (d.getDecision() == null) d.setDecision(opts.get(req.selectedIndex())); + } + } + return d; } // DecisionNode → DecLineDto 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 48bb80d..5afe7d4 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 @@ -13,4 +13,7 @@ @Repository public interface BaseLineRepository extends JpaRepository { Optional findByUser(User user); + long countByUser(User user); // 기본 인덱스 계산용 + + boolean existsByUserAndTitle(User user, String title); // 충돌 회피용 } \ No newline at end of file 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 new file mode 100644 index 0000000..9da5c67 --- /dev/null +++ b/back/src/main/java/com/back/domain/node/service/BaseLineService.java @@ -0,0 +1,74 @@ +/** + * BaseLineService + * - 베이스라인 일괄 생성, 피벗 목록 반환 + * - 입력 검증/제목 생성 등 공통 로직은 NodeDomainSupport에 위임 + */ +package com.back.domain.node.service; + +import com.back.domain.node.dto.BaseLineBulkCreateRequest; +import com.back.domain.node.dto.BaseLineBulkCreateResponse; +import com.back.domain.node.dto.PivotListDto; +import com.back.domain.node.entity.BaseLine; +import com.back.domain.node.entity.BaseNode; +import com.back.domain.node.mapper.NodeMappers; +import com.back.domain.node.repository.BaseLineRepository; +import com.back.domain.node.repository.BaseNodeRepository; +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +@RequiredArgsConstructor +class BaseLineService { + + private final BaseLineRepository baseLineRepository; + private final BaseNodeRepository baseNodeRepository; + private final UserRepository userRepository; + private final NodeDomainSupport support; + + // 가장 많이 사용하는: 일괄 생성(save chain) + public BaseLineBulkCreateResponse createBaseLineWithNodes(BaseLineBulkCreateRequest request) { + support.validateBulkRequest(request); + User user = userRepository.findById(request.userId()) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND, "User not found: " + request.userId())); + String title = support.normalizeOrAutoTitle(request.title(), user); + + BaseLine baseLine = baseLineRepository.save(BaseLine.builder().user(user).title(title).build()); + + BaseNode prev = null; + List created = new ArrayList<>(); + for (int i = 0; i < request.nodes().size(); i++) { + BaseLineBulkCreateRequest.BaseNodePayload payload = request.nodes().get(i); + BaseNode entity = new NodeMappers.BaseNodeCtxMapper(user, baseLine, prev).toEntity(payload); + entity.guardBaseOptionsValid(); + BaseNode saved = baseNodeRepository.save(entity); + created.add(new BaseLineBulkCreateResponse.CreatedNode(i, saved.getId())); + prev = saved; + } + return new BaseLineBulkCreateResponse(baseLine.getId(), created); + } + + // 가장 중요한: 피벗 목록 조회(헤더/꼬리 제외 + 중복 나이 제거 + 오름차순) + public PivotListDto getPivotBaseNodes(Long baseLineId) { + List ordered = support.getOrderedBaseNodes(baseLineId); + if (ordered.size() <= 2) return new PivotListDto(baseLineId, List.of()); + + Map uniqByAge = new LinkedHashMap<>(); + for (int i = 1; i < ordered.size() - 1; i++) { + BaseNode n = ordered.get(i); + uniqByAge.putIfAbsent(n.getAgeYear(), n); + } + + List list = new ArrayList<>(); + int idx = 0; + for (BaseNode n : uniqByAge.values()) { + list.add(new PivotListDto.PivotDto(idx++, n.getId(), n.getCategory(), n.getSituation(), n.getAgeYear())); + } + return new PivotListDto(baseLineId, list); + } +} 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 new file mode 100644 index 0000000..f189c09 --- /dev/null +++ b/back/src/main/java/com/back/domain/node/service/DecisionFlowService.java @@ -0,0 +1,153 @@ +/** + * DecisionFlowService + * - from-base: 피벗 해석 + 분기 텍스트 반영 + 선택 슬롯 링크 + 첫 결정 생성 + * - next : 부모 기준 다음 피벗/나이 해석 + 매칭 + 연속 결정 생성 + * - cancel/complete: 라인 상태 전이 + */ +package com.back.domain.node.service; + +import com.back.domain.node.dto.*; +import com.back.domain.node.entity.*; +import com.back.domain.node.mapper.NodeMappers; +import com.back.domain.node.repository.BaseNodeRepository; +import com.back.domain.node.repository.DecisionLineRepository; +import com.back.domain.node.repository.DecisionNodeRepository; +import com.back.global.exception.ApiException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +class DecisionFlowService { + + private final DecisionLineRepository decisionLineRepository; + private final DecisionNodeRepository decisionNodeRepository; + private final BaseNodeRepository baseNodeRepository; + private final NodeDomainSupport support; + + // 가장 중요한: from-base 서버 해석(피벗 슬롯 텍스트 입력 허용 + 선택 슬롯만 링크) + public DecLineDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request) { + if (request == null || request.baseLineId() == null) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "baseLineId is required"); + + List 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()); + + List opts = request.options(); + if (opts != null && !opts.isEmpty()) { + support.validateOptions(opts, request.selectedIndex(), + request.selectedIndex() != null ? opts.get(request.selectedIndex()) : null); + support.ensurePivotAltTexts(pivot, opts); + 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) + 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() + ); + + String situation = (request.situation() != null) ? request.situation() : pivot.getSituation(); + String background = support.resolveBackground(situation); + + 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"); + } + + DecisionNodeCreateRequestDto createReq = new DecisionNodeCreateRequestDto( + line.getId(), null, pivot.getId(), + request.category() != null ? request.category() : pivot.getCategory(), + situation, finalDecision, pivotAge, + opts, request.selectedIndex(), null + ); + + DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq)); + if (sel == 0) pivot.setAltOpt1TargetDecisionId(saved.getId()); else pivot.setAltOpt2TargetDecisionId(saved.getId()); + baseNodeRepository.save(pivot); + return mapper.toResponse(saved); + } + + // 가장 중요한: next 서버 해석(부모 기준 라인/다음 피벗/베이스 매칭 결정) + public DecLineDto createDecisionNodeNext(DecisionNodeNextRequest request) { + if (request == null || request.parentDecisionNodeId() == null) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "parentDecisionNodeId is required"); + + DecisionNode parent = decisionNodeRepository.findById(request.parentDecisionNodeId()) + .orElseThrow(() -> new ApiException(ErrorCode.NODE_NOT_FOUND, "Parent DecisionNode not found: " + request.parentDecisionNodeId())); + DecisionLine line = parent.getDecisionLine(); + if (line.getStatus() == DecisionLineStatus.COMPLETED || line.getStatus() == DecisionLineStatus.CANCELLED) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "line is locked"); + + List ordered = support.getOrderedBaseNodes(line.getBaseLine().getId()); + int nextAge = support.resolveNextAge(request.ageYear(), parent.getAgeYear(), + support.allowedPivotAges(ordered)); + support.ensureAgeVacant(line, nextAge); + BaseNode matchedBase = support.findBaseNodeByAge(ordered, nextAge); + + if (request.options() != null && !request.options().isEmpty()) { + support.validateOptions(request.options(), request.selectedIndex(), + request.selectedIndex() != null ? request.options().get(request.selectedIndex()) : null); + } + + String situation = (request.situation() != null) ? request.situation() : parent.getSituation(); + String background = support.resolveBackground(situation); + + NodeMappers.DecisionNodeCtxMapper mapper = + new NodeMappers.DecisionNodeCtxMapper(parent.getUser(), line, parent, matchedBase, background); + + String finalDecision = + (request.options() != null && !request.options().isEmpty() + && request.selectedIndex() != null + && request.selectedIndex() >= 0 + && request.selectedIndex() < request.options().size()) + ? request.options().get(request.selectedIndex()) + : request.situation(); + + DecisionNodeCreateRequestDto createReq = new DecisionNodeCreateRequestDto( + line.getId(), parent.getId(), matchedBase != null ? matchedBase.getId() : null, + request.category() != null ? request.category() : parent.getCategory(), + situation, finalDecision, nextAge, + request.options(), request.selectedIndex(), request.parentOptionIndex() + ); + + DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq)); + return mapper.toResponse(saved); + } + + // 라인 취소 + public DecisionLineLifecycleDto cancelDecisionLine(Long decisionLineId) { + DecisionLine line = support.requireDecisionLine(decisionLineId); + try { line.cancel(); } catch (RuntimeException e) { throw support.mapDomainToApi(e); } + decisionLineRepository.save(line); + return new DecisionLineLifecycleDto(line.getId(), line.getStatus()); + } + + // 라인 완료 + public DecisionLineLifecycleDto completeDecisionLine(Long decisionLineId) { + DecisionLine line = support.requireDecisionLine(decisionLineId); + try { line.complete(); } catch (RuntimeException e) { throw support.mapDomainToApi(e); } + decisionLineRepository.save(line); + return new DecisionLineLifecycleDto(line.getId(), line.getStatus()); + } +} diff --git a/back/src/main/java/com/back/domain/node/service/NodeDomainSupport.java b/back/src/main/java/com/back/domain/node/service/NodeDomainSupport.java new file mode 100644 index 0000000..6981cfe --- /dev/null +++ b/back/src/main/java/com/back/domain/node/service/NodeDomainSupport.java @@ -0,0 +1,188 @@ +/** + * NodeDomainSupport (공통 헬퍼) + * - 입력 검증, 피벗/나이 해석, 정렬 조회, 옵션 검증, 제목 생성, 예외 매핑 등 + */ +package com.back.domain.node.service; + +import com.back.domain.node.dto.BaseLineBulkCreateRequest; +import com.back.domain.node.entity.*; +import com.back.domain.node.repository.BaseLineRepository; +import com.back.domain.node.repository.BaseNodeRepository; +import com.back.domain.node.repository.DecisionLineRepository; +import com.back.domain.user.entity.User; +import com.back.global.exception.ApiException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.*; + +@Component +@RequiredArgsConstructor +class NodeDomainSupport { + + private static final int MAX_SITUATION_LEN = 1000; + private static final int MAX_TITLE_LEN = 100; + private static final String UNTITLED_PREFIX = "제목없음"; + + private final BaseLineRepository baseLineRepository; + private final BaseNodeRepository baseNodeRepository; + private final DecisionLineRepository decisionLineRepository; + + // BaseLine 존재 보장 + public void ensureBaseLineExists(Long baseLineId) { + baseLineRepository.findById(baseLineId) + .orElseThrow(() -> new ApiException(ErrorCode.BASE_LINE_NOT_FOUND, "BaseLine not found: " + baseLineId)); + } + + // BaseLine 정렬된 노드 조회 + 라인 존재 확인 + public List getOrderedBaseNodes(Long baseLineId) { + BaseLine baseLine = baseLineRepository.findById(baseLineId) + .orElseThrow(() -> new ApiException(ErrorCode.BASE_LINE_NOT_FOUND, "BaseLine not found: " + baseLineId)); + List nodes = baseNodeRepository.findByBaseLine_IdOrderByAgeYearAscIdAsc(baseLine.getId()); + return nodes == null ? List.of() : nodes; + } + + // 허용 피벗 나이 목록(헤더/꼬리 제외, distinct asc) + public List allowedPivotAges(List ordered) { + if (ordered.size() <= 2) return List.of(); + LinkedHashSet set = new LinkedHashSet<>(); + for (int i = 1; i < ordered.size() - 1; i++) set.add(ordered.get(i).getAgeYear()); + List ages = new ArrayList<>(set); + ages.sort(Comparator.naturalOrder()); + return ages; + } + + // 피벗 나이 해석(순번/나이) + public int resolvePivotAge(Integer pivotOrd, Integer pivotAge, List pivotAges) { + if (pivotAge != null) { + if (!pivotAges.contains(pivotAge)) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "invalid pivotAge"); + return pivotAge; + } + if (pivotOrd == null || pivotOrd < 0 || pivotOrd >= pivotAges.size()) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "invalid pivotOrd"); + return pivotAges.get(pivotOrd); + } + + // 다음 나이 해석(명시 없으면 부모보다 큰 첫 피벗) + public int resolveNextAge(Integer requested, int parentAge, List pivotAges) { + if (requested != null) { + if (!pivotAges.contains(requested) || requested <= parentAge) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "invalid next age"); + return requested; + } + return pivotAges.stream().filter(a -> a > parentAge) + .findFirst().orElseThrow(() -> new ApiException(ErrorCode.INVALID_INPUT_VALUE, "no more pivots")); + } + + // 피벗 나이로 BaseNode 찾기 + public BaseNode findBaseNodeByAge(List ordered, int age) { + for (BaseNode b : ordered) if (b.getAgeYear() == age) return b; + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "pivot base node not found for age " + age); + } + + // 같은 라인에서 해당 나이 중복 방지 + public void ensureAgeVacant(DecisionLine line, int ageYear) { + for (DecisionNode d : line.getDecisionNodes()) if (d.getAgeYear() == ageYear) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "decision already exists at this age"); + } + + // 베이스 분기 슬롯 인덱스 검증 + public int requireAltIndex(Integer idx) { + if (idx == null || (idx != 0 && idx != 1)) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "selectedAltIndex must be 0 or 1"); + return idx; + } + + // 옵션 1~3, selectedIndex/decision 일관성 검증 + public void validateOptions(List 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"); + 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"); + if (decision != null && selectedIndex != null) { + if (!Objects.equals(decision, options.get(selectedIndex))) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "decision must equal options[selectedIndex]"); + } + } + + // 피벗 alt 슬롯 텍스트 채우기/검증 + public void ensurePivotAltTexts(BaseNode pivot, List 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(); + if (t == null) { + long seq = Math.max(1, baseLineRepository.countByUser(user) + 1); + String candidate; + do { + candidate = UNTITLED_PREFIX + seq; + seq++; + } while (candidate.length() <= MAX_TITLE_LEN + && baseLineRepository.existsByUserAndTitle(user, candidate)); + t = candidate; + } + if (t.length() > MAX_TITLE_LEN) t = t.substring(0, MAX_TITLE_LEN); + return t; + } + + // 요청 전체 유효성 검증 + public void validateBulkRequest(BaseLineBulkCreateRequest request) { + if (request == null) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "request must not be null"); + if (request.userId() == null) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "userId is required"); + if (request.nodes() == null || request.nodes().isEmpty()) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "nodes must not be empty"); + if (request.nodes().size() < 2) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "nodes length must be >= 2 (header and tail required)"); + for (int i = 0; i < request.nodes().size(); i++) { + BaseLineBulkCreateRequest.BaseNodePayload p = request.nodes().get(i); + if (p.category() == null) throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "category is required at index=" + i); + if (p.ageYear() == null || p.ageYear() < 0) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "ageYear must be >= 0 at index=" + i); + String s = Optional.ofNullable(p.situation()).orElse(""); + if (s.length() > MAX_SITUATION_LEN) + throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "situation length exceeds " + MAX_SITUATION_LEN + " at index=" + i); + } + } + + // 도메인 런타임 예외 → ApiException + public RuntimeException mapDomainToApi(RuntimeException e) { + return new ApiException(ErrorCode.INVALID_INPUT_VALUE, e.getMessage()); + } + + // DecisionLine 필수 조회 + public DecisionLine requireDecisionLine(Long decisionLineId) { + return decisionLineRepository.findById(decisionLineId) + .orElseThrow(() -> new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND, "DecisionLine not found: " + decisionLineId)); + } + + // 배경 생성 훅 + public String resolveBackground(String situation) { + return situation == null ? "" : situation; + } +} 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 new file mode 100644 index 0000000..67a695b --- /dev/null +++ b/back/src/main/java/com/back/domain/node/service/NodeQueryService.java @@ -0,0 +1,66 @@ +/** + * NodeQueryService + * - 트리 조회, 라인 노드 목록, 단건 노드 조회(읽기 전용) + */ +package com.back.domain.node.service; + +import com.back.domain.node.dto.BaseNodeDto; +import com.back.domain.node.dto.DecLineDto; +import com.back.domain.node.dto.TreeDto; +import com.back.domain.node.entity.BaseNode; +import com.back.domain.node.entity.DecisionLine; +import com.back.domain.node.entity.DecisionNode; +import com.back.domain.node.mapper.NodeMappers; +import com.back.domain.node.repository.BaseNodeRepository; +import com.back.domain.node.repository.DecisionLineRepository; +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +class NodeQueryService { + + private final UserRepository userRepository; + private final BaseNodeRepository baseNodeRepository; + private final DecisionLineRepository decisionLineRepository; + private final NodeDomainSupport support; + + // 가장 중요한: 트리 전체 조회 + public TreeDto getTreeInfo(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND, "User not found: " + userId)); + + List baseDtos = new ArrayList<>(); + for (BaseNode n : baseNodeRepository.findByUser(user)) baseDtos.add(NodeMappers.BASE_READ.map(n)); + + List decDtos = new ArrayList<>(); + for (DecisionLine line : decisionLineRepository.findByUser(user)) + for (DecisionNode dn : line.getDecisionNodes()) decDtos.add(NodeMappers.DECISION_READ.map(dn)); + + return new TreeDto(baseDtos, decDtos); + } + + // 가장 많이 사용하는: BaseLine 노드 정렬 조회(나이 asc) + public List getBaseLineNodes(Long baseLineId) { + support.ensureBaseLineExists(baseLineId); + List nodes = baseNodeRepository.findByBaseLine_IdOrderByAgeYearAscIdAsc(baseLineId); + if (nodes == null || nodes.isEmpty()) return List.of(); + List result = new ArrayList<>(nodes.size()); + for (BaseNode n : nodes) result.add(NodeMappers.BASE_READ.map(n)); + return result; + } + + // 가장 많이 사용하는: BaseNode 단건 조회 + public BaseNodeDto getBaseNode(Long baseNodeId) { + BaseNode node = baseNodeRepository.findById(baseNodeId) + .orElseThrow(() -> new ApiException(ErrorCode.NODE_NOT_FOUND, "BaseNode not found: " + baseNodeId)); + return NodeMappers.BASE_READ.map(node); + } +} 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 64aa87a..8d5d7e9 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 @@ -1,314 +1,68 @@ /** - * NodeService (매퍼 중심 사용) - * - write: 요청DTO → 매퍼.toEntity → save → 매퍼.toResponse - * - read : 엔티티 → 전역 Mapper(S->T)로 DTO 변환 (정적 from 미사용) - * - 에러: 도메인 런타임 예외 → ApiException(INVALID_INPUT_VALUE)로 일괄 매핑, 존재하지 않는 리소스는 *_NOT_FOUND - * - 배경문: 현재는 situation 그대로 사용. 나중에 AI 붙일 때 resolveBackground(...) 내부만 교체. + * NodeService (파사드) + * - 컨트롤러에서 사용하는 퍼블릭 API를 그대로 유지하고, 실제 로직은 서브서비스로 위임 + * - BaseLineService / DecisionFlowService / NodeQueryService 로 관심사 분리 */ package com.back.domain.node.service; -import com.back.domain.node.dto.BaseLineBulkCreateRequest; -import com.back.domain.node.dto.BaseLineBulkCreateResponse; -import com.back.domain.node.dto.BaseLineDto; -import com.back.domain.node.dto.DecLineDto; -import com.back.domain.node.dto.DecisionLineLifecycleDto; -import com.back.domain.node.dto.DecisionNodeFromBaseRequest; -import com.back.domain.node.dto.DecisionNodeNextRequest; -import com.back.domain.node.dto.PivotListDto; -import com.back.domain.node.dto.TreeDto; -import com.back.domain.node.dto.DecisionNodeCreateRequestDto; -import com.back.domain.node.entity.BaseLine; -import com.back.domain.node.entity.BaseNode; -import com.back.domain.node.entity.DecisionLine; -import com.back.domain.node.entity.DecisionLineStatus; -import com.back.domain.node.entity.DecisionNode; -import com.back.domain.node.mapper.NodeMappers; -import com.back.domain.node.repository.BaseLineRepository; -import com.back.domain.node.repository.BaseNodeRepository; -import com.back.domain.node.repository.DecisionLineRepository; -import com.back.domain.node.repository.DecisionNodeRepository; -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 com.back.domain.node.dto.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; import java.util.List; -import java.util.Objects; @Service @Transactional @RequiredArgsConstructor public class NodeService { - private final BaseLineRepository baseLineRepository; - private final BaseNodeRepository baseNodeRepository; - private final DecisionLineRepository decisionLineRepository; - private final DecisionNodeRepository decisionNodeRepository; - private final UserRepository userRepository; + private final BaseLineService baseLineService; + private final DecisionFlowService decisionFlowService; + private final NodeQueryService nodeQueryService; - // 트리 전체 조회: 엔티티 → 전역 읽기 매퍼로 DTO 변환 + // 트리 전체 조회 위임 public TreeDto getTreeInfo(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND, "User not found: " + userId)); - - List baseDtos = new ArrayList<>(); - for (BaseNode n : baseNodeRepository.findByUser(user)) { - baseDtos.add(NodeMappers.BASE_READ.map(n)); - } - - List decDtos = new ArrayList<>(); - for (DecisionLine line : decisionLineRepository.findByUser(user)) { - for (DecisionNode dn : line.getDecisionNodes()) { - decDtos.add(NodeMappers.DECISION_READ.map(dn)); - } - } - return new TreeDto(baseDtos, decDtos); + return nodeQueryService.getTreeInfo(userId); } - // BaseLine 일괄 생성: payload → 매퍼 → save → 매퍼.toResponse + // BaseLine 일괄 생성 위임 public BaseLineBulkCreateResponse createBaseLineWithNodes(BaseLineBulkCreateRequest request) { - if (request == null || request.nodes() == null || request.nodes().isEmpty()) { - throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "nodes must not be empty"); - } - if (request.nodes().size() < 2) { - throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "nodes length must be >= 2 (header and tail required)"); - } - - User user = (request.userId() != null) - ? userRepository.findById(request.userId()) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND, "User not found: " + request.userId())) - : null; - - BaseLine baseLine = baseLineRepository.save(BaseLine.builder().user(user).build()); - - BaseNode prev = null; - List created = new ArrayList<>(); - - for (int i = 0; i < request.nodes().size(); i++) { - BaseLineBulkCreateRequest.BaseNodePayload payload = request.nodes().get(i); - NodeMappers.BaseNodeCtxMapper mapper = new NodeMappers.BaseNodeCtxMapper(user, baseLine, prev); - BaseNode saved = baseNodeRepository.save(mapper.toEntity(payload)); - created.add(new BaseLineBulkCreateResponse.CreatedNode(i, saved.getId())); - prev = saved; - } - return new BaseLineBulkCreateResponse(baseLine.getId(), created); + return baseLineService.createBaseLineWithNodes(request); } - // 피벗 목록 조회(헤더/꼬리 제외) + // 피벗 목록 조회 위임 public PivotListDto getPivotBaseNodes(Long baseLineId) { - List ordered = getOrderedBaseNodes(baseLineId); - if (ordered.size() <= 2) return new PivotListDto(baseLineId, List.of()); - List list = new ArrayList<>(); - for (int i = 1; i < ordered.size() - 1; i++) { - BaseNode n = ordered.get(i); - list.add(new PivotListDto.PivotDto(i, n.getId(), n.getCategory(), n.getSituation(), n.getAgeYear())); - } - return new PivotListDto(baseLineId, list); + return baseLineService.getPivotBaseNodes(baseLineId); } - // 첫 DecisionNode 생성(피벗에서): 규칙 검증 → 배경 생성(현재는 pass-through) → 매퍼로 생성 + // from-base 생성 위임 public DecLineDto createDecisionNodeFromBase(DecisionNodeFromBaseRequest request) { - if (request == null || request.baseLineId() == null || request.pivotBaseNodeId() == null) { - throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "baseLineId and pivotBaseNodeId are required"); - } - - BaseLine baseLine = baseLineRepository.findById(request.baseLineId()) - .orElseThrow(() -> new ApiException(ErrorCode.BASE_LINE_NOT_FOUND, "BaseLine not found: " + request.baseLineId())); - BaseNode pivot = baseNodeRepository.findById(request.pivotBaseNodeId()) - .orElseThrow(() -> new ApiException(ErrorCode.NODE_NOT_FOUND, "BaseNode (pivot) not found: " + request.pivotBaseNodeId())); - - List ordered = getOrderedBaseNodes(baseLine.getId()); - int pivotIdx = indexOfNode(ordered, pivot.getId()); - if (pivotIdx <= 0 || pivotIdx >= ordered.size() - 1) { - throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "pivot must be one of middle nodes (not header/tail)"); - } - - int pivotAge = pivot.getAgeYear(); - int age = request.ageYear() != null ? request.ageYear() : pivotAge; - if (age != pivotAge) { - throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "first decision ageYear must equal pivot ageYear"); - } - - DecisionLine line = decisionLineRepository.save( - DecisionLine.builder() - .user(baseLine.getUser()) - .baseLine(baseLine) - .status(DecisionLineStatus.DRAFT) - .build() - ); - - String situation = request.situation() != null ? request.situation() : pivot.getSituation(); - String background = resolveBackground(situation); // 현재는 situation 그대로 - - NodeMappers.DecisionNodeCtxMapper mapper = - new NodeMappers.DecisionNodeCtxMapper(baseLine.getUser(), line, null, pivot, background); - - DecisionNodeCreateRequestDto createReq = new DecisionNodeCreateRequestDto( - line.getId(), null, pivot.getId(), - request.category() != null ? request.category() : pivot.getCategory(), - situation, - request.decision(), age - ); - - DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq)); - return mapper.toResponse(saved); + return decisionFlowService.createDecisionNodeFromBase(request); } - // 다음 DecisionNode 생성(연속): 다음 피벗 나이 자동 선택 지원 + 배경(pass-through) + // next 생성 위임 public DecLineDto createDecisionNodeNext(DecisionNodeNextRequest request) { - if (request == null || request.parentDecisionNodeId() == null) { - throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "parentDecisionNodeId is required"); - } - - DecisionNode parent = decisionNodeRepository.findById(request.parentDecisionNodeId()) - .orElseThrow(() -> new ApiException(ErrorCode.NODE_NOT_FOUND, "Parent DecisionNode not found: " + request.parentDecisionNodeId())); - - DecisionLine line = (request.decisionLineId() != null) - ? decisionLineRepository.findById(request.decisionLineId()) - .orElseThrow(() -> new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND, "DecisionLine not found: " + request.decisionLineId())) - : parent.getDecisionLine(); - - if (line.getStatus() == DecisionLineStatus.COMPLETED || line.getStatus() == DecisionLineStatus.CANCELLED) { - throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "cannot append to a completed or cancelled decision line"); - } - - BaseLine baseLine = line.getBaseLine(); - List ordered = getOrderedBaseNodes(baseLine.getId()); - List pivotAges = allowedPivotAges(ordered); - - int parentAge = parent.getAgeYear(); - Integer nextAge = request.ageYear(); - if (nextAge == null) { - nextAge = pivotAges.stream().filter(a -> a > parentAge).findFirst() - .orElseThrow(() -> new ApiException(ErrorCode.INVALID_INPUT_VALUE, "no more pivot ages available")); - } - if (!pivotAges.contains(nextAge) || nextAge <= parentAge) { - throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "invalid next pivot age"); - } - - int maxPivot = pivotAges.get(pivotAges.size() - 1); - if (nextAge > maxPivot) { - throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "ageYear cannot exceed last pivot age of base line"); - } - - for (DecisionNode d : line.getDecisionNodes()) { - if (d.getAgeYear() == nextAge) { - throw new ApiException(ErrorCode.INVALID_INPUT_VALUE, "a decision node already exists at this pivot age"); - } - } - - BaseNode matchedBase = null; - for (BaseNode b : ordered) { - if (b.getAgeYear() == nextAge) { matchedBase = b; break; } - } - - String situation = request.situation() != null ? request.situation() : parent.getSituation(); - String background = resolveBackground(situation); // 현재는 situation 그대로 - - NodeMappers.DecisionNodeCtxMapper mapper = - new NodeMappers.DecisionNodeCtxMapper(parent.getUser(), line, parent, matchedBase, background); - - DecisionNodeCreateRequestDto createReq = new DecisionNodeCreateRequestDto( - line.getId(), parent.getId(), matchedBase != null ? matchedBase.getId() : null, - request.category() != null ? request.category() : parent.getCategory(), - situation, - request.decision(), nextAge - ); - - DecisionNode saved = decisionNodeRepository.save(mapper.toEntity(createReq)); - return mapper.toResponse(saved); + return decisionFlowService.createDecisionNodeNext(request); } - // 결정 라인 취소 + // 라인 취소 위임 public DecisionLineLifecycleDto cancelDecisionLine(Long decisionLineId) { - DecisionLine line = requireDecisionLine(decisionLineId); - try { - line.cancel(); - } catch (RuntimeException e) { - throw mapDomainToApi(e); - } - decisionLineRepository.save(line); - return new DecisionLineLifecycleDto(line.getId(), line.getStatus()); + return decisionFlowService.cancelDecisionLine(decisionLineId); } - // 결정 라인 완료 + // 라인 완료 위임 public DecisionLineLifecycleDto completeDecisionLine(Long decisionLineId) { - DecisionLine line = requireDecisionLine(decisionLineId); - try { - line.complete(); - } catch (RuntimeException e) { - throw mapDomainToApi(e); - } - decisionLineRepository.save(line); - return new DecisionLineLifecycleDto(line.getId(), line.getStatus()); - } - - // BaseLine 노드 정렬 조회(나이 asc) - private List getOrderedBaseNodes(Long baseLineId) { - BaseLine baseLine = baseLineRepository.findById(baseLineId) - .orElseThrow(() -> new ApiException(ErrorCode.BASE_LINE_NOT_FOUND, "BaseLine not found: " + baseLineId)); - List nodes = baseNodeRepository.findByBaseLine_IdOrderByAgeYearAscIdAsc(baseLine.getId()); - return nodes == null ? List.of() : nodes; - } - - // id 인덱스 찾기 - private int indexOfNode(List ordered, Long baseNodeId) { - for (int i = 0; i < ordered.size(); i++) { - if (Objects.equals(ordered.get(i).getId(), baseNodeId)) return i; - } - return -1; - } - - // 허용 피벗 나이 목록(헤더/꼬리 제외, 중복 제거) - private List allowedPivotAges(List ordered) { - if (ordered.size() <= 2) return List.of(); - List ages = new ArrayList<>(); - for (int i = 1; i < ordered.size() - 1; i++) { - ages.add(ordered.get(i).getAgeYear()); - } - // distinct + 오름차순 - List distinctSorted = new ArrayList<>(); - ages.stream().sorted().forEach(a -> { - if (distinctSorted.isEmpty() || !distinctSorted.get(distinctSorted.size() - 1).equals(a)) { - distinctSorted.add(a); - } - }); - return distinctSorted; - } - - // BaseLine 노드 목록 조회: 엔티티 → 전역 읽기 매퍼 - public List getBaseLineNodes(Long baseLineId) { - List nodes = baseNodeRepository.findByBaseLine_IdOrderByAgeYearAscIdAsc(baseLineId); - if (nodes == null || nodes.isEmpty()) return List.of(); - List result = new ArrayList<>(nodes.size()); - for (BaseNode n : nodes) result.add(NodeMappers.BASE_READ.map(n)); - return result; - } - - // BaseNode 단건 조회: 엔티티 → 전역 읽기 매퍼 - public BaseLineDto getBaseNode(Long baseNodeId) { - BaseNode node = baseNodeRepository.findById(baseNodeId) - .orElseThrow(() -> new ApiException(ErrorCode.NODE_NOT_FOUND, "BaseNode not found: " + baseNodeId)); - return NodeMappers.BASE_READ.map(node); - } - - // 도메인 런타임 예외 → ApiException(INVALID_INPUT_VALUE)로 매핑 - private RuntimeException mapDomainToApi(RuntimeException e) { - return new ApiException(ErrorCode.INVALID_INPUT_VALUE, e.getMessage()); + return decisionFlowService.completeDecisionLine(decisionLineId); } - // DecisionLine 필수 조회 - private DecisionLine requireDecisionLine(Long decisionLineId) { - return decisionLineRepository.findById(decisionLineId) - .orElseThrow(() -> new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND, "DecisionLine not found: " + decisionLineId)); + // BaseLine 노드 정렬 조회 위임 + public List getBaseLineNodes(Long baseLineId) { + return nodeQueryService.getBaseLineNodes(baseLineId); } - // --- 배경 생성 훅: 지금은 pass-through. 나중에 AI 붙일 때 이 내부만 수정 --- - private String resolveBackground(String situation) { - return situation == null ? "" : situation; + // BaseNode 단건 조회 위임 + public BaseNodeDto getBaseNode(Long baseNodeId) { + return nodeQueryService.getBaseNode(baseNodeId); } } diff --git a/back/src/main/java/com/back/global/config/JsonConfig.java b/back/src/main/java/com/back/global/config/JsonConfig.java index 050a354..798b184 100644 --- a/back/src/main/java/com/back/global/config/JsonConfig.java +++ b/back/src/main/java/com/back/global/config/JsonConfig.java @@ -1,9 +1,12 @@ package com.back.global.config; +import com.back.global.jackson.ProblemDetailJsonSerializer; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.ProblemDetail; /** * JSON 처리를 위한 ObjectMapper 설정 클래스. @@ -23,6 +26,10 @@ public ObjectMapper objectMapper() { // Java 8 시간 타입 지원 (LocalDateTime 등) mapper.registerModule(new JavaTimeModule()); + SimpleModule pdModule = new SimpleModule(); + pdModule.addSerializer(ProblemDetail.class, new ProblemDetailJsonSerializer()); + mapper.registerModule(pdModule); + // 알려지지 않은 속성이 있어도 JSON 파싱 실패하지 않음 (알려지지 않은 속성 무시) mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); diff --git a/back/src/main/java/com/back/global/exception/GlobalExceptionHandler.java b/back/src/main/java/com/back/global/exception/GlobalExceptionHandler.java index 9b88a33..374f6da 100644 --- a/back/src/main/java/com/back/global/exception/GlobalExceptionHandler.java +++ b/back/src/main/java/com/back/global/exception/GlobalExceptionHandler.java @@ -1,46 +1,110 @@ +/** + * GlobalExceptionHandler + * - 도메인/서비스에서 던진 ApiException과 일반 예외를 RFC7807 ProblemDetail로 일관 변환 + * - 바인딩/검증/타입 불일치 등 스프링 표준 예외도 공통 포맷으로 응답 + * - 추가 속성(code, path, timestamp, traceId 등)을 포함해 디버깅과 표준화 동시 달성 + */ package com.back.global.exception; +import com.back.global.problemdetail.ProblemDetails; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; // NOTE: MVC 바인딩 예외 import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.HashMap; +import java.util.Map; -/** - * 전역 예외 처리 핸들러. - * 애플리케이션 전반에서 발생하는 예외를 중앙에서 처리하여 - * 일관된 에러 응답을 제공합니다. - */ @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { + // ApiException → ProblemDetail @ExceptionHandler(ApiException.class) - protected ResponseEntity handleApiException(ApiException e, HttpServletRequest request) { - // 커스텀 비즈니스 예외 처리 - log.error("ApiException: {}", e.getMessage()); - return ResponseEntity - .status(e.getErrorCode().getStatus()) - .body(ErrorResponse.of(e.getErrorCode().getStatus(), e.getErrorCode(), e.getMessage(), request.getRequestURI())); + public ProblemDetail handleApiException(ApiException ex, HttpServletRequest req) { + // ApiException을 ProblemDetail로 변환 + return ProblemDetails.of(ex.getErrorCode(), req, ex.getMessage()); } + // @Valid 바인딩 에러(필드 단위) @ExceptionHandler(MethodArgumentNotValidException.class) - protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request) { - // @Valid 어노테이션을 사용한 유효성 검사 실패 예외 처리 - log.error("MethodArgumentNotValidException: {}", e.getMessage()); - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult().getAllErrors().get(0).getDefaultMessage(), request.getRequestURI())); + public ProblemDetail handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpServletRequest req) { + // 필드 에러 리스트 프로퍼티로 첨부 + var fieldErrors = ex.getBindingResult().getFieldErrors().stream() + .map(fe -> Map.of( + "field", fe.getField(), + "rejectedValue", fe.getRejectedValue(), + "message", fe.getDefaultMessage())) + .toList(); + + Map props = new HashMap<>(); + props.put("fieldErrors", fieldErrors); + + // INVALID_INPUT_VALUE 포맷으로 생성 + return ProblemDetails.of( + ErrorCode.INVALID_INPUT_VALUE.getStatus(), + ErrorCode.INVALID_INPUT_VALUE.name(), + ErrorCode.INVALID_INPUT_VALUE.getMessage(), + ErrorCode.INVALID_INPUT_VALUE.getCode(), + props, + req + ); + } + + // 바인딩/제약조건/메시지 파싱 에러 묶음 + @ExceptionHandler({ + BindException.class, + ConstraintViolationException.class, + HttpMessageNotReadableException.class + }) + public ProblemDetail handleBindingLikeExceptions(Exception ex, HttpServletRequest req) { + // 상세 메시지 포함하여 INVALID_INPUT_VALUE로 응답 + return ProblemDetails.of(ErrorCode.INVALID_INPUT_VALUE, req, ex.getMessage()); + } + + // 경로/파라미터 타입 불일치 → 400 + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ProblemDetail handleTypeMismatch(MethodArgumentTypeMismatchException ex, HttpServletRequest req) { + // 가장 많이 나는 타입 오류를 일관 포맷으로 + String detail = "Invalid path or query parameter type: %s".formatted(ex.getName()); + return ProblemDetails.of( + HttpStatus.BAD_REQUEST, + ErrorCode.INVALID_TYPE_VALUE.name(), + detail, + ErrorCode.INVALID_TYPE_VALUE.getCode(), + Map.of( + "parameter", ex.getName(), + "requiredType", ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown", + "value", ex.getValue() + ), + req + ); + } + + // IllegalArgument → 400 + @ExceptionHandler(IllegalArgumentException.class) + public ProblemDetail handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest req) { + // 자주 쓰이는 유효성 실패를 공통 포맷으로 + return ProblemDetails.of(ErrorCode.INVALID_INPUT_VALUE, req, ex.getMessage()); } + // 최종 안전망 @ExceptionHandler(Exception.class) - protected ResponseEntity handleException(Exception e, HttpServletRequest request) { - // 모든 예상치 못한 예외 처리 - log.error("Exception: {}", e.getMessage()); - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR, e.getMessage(), request.getRequestURI())); + public ProblemDetail handleAny(Exception ex, HttpServletRequest req) { + log.error("Unhandled exception occurred", ex); + // 예상치 못한 예외도 표준 포맷으로 + var pd = ProblemDetails.of(ErrorCode.INTERNAL_SERVER_ERROR, req, ex.getMessage()); + // 가장 많이 쓰이는 로깅 트레이스ID를 프로퍼티에 추가 + String traceId = MDC.get("traceId"); + if (traceId != null) pd.setProperty("traceId", traceId); + return pd; } -} \ No newline at end of file +} diff --git a/back/src/main/java/com/back/global/jackson/ProblemDetailJsonSerializer.java b/back/src/main/java/com/back/global/jackson/ProblemDetailJsonSerializer.java new file mode 100644 index 0000000..a84b13c --- /dev/null +++ b/back/src/main/java/com/back/global/jackson/ProblemDetailJsonSerializer.java @@ -0,0 +1,45 @@ +/** + * [SERIALIZER] ProblemDetail → JSON (RFC7807 + 확장 필드 플래튼) + * - 표준 필드(type/title/status/detail/instance) + properties(Map)을 JSON 루트로 평탄화 + */ +package com.back.global.jackson; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import org.springframework.boot.jackson.JsonComponent; +import org.springframework.http.ProblemDetail; + +import java.io.IOException; +import java.net.URI; +import java.util.Map; + +@JsonComponent +public class ProblemDetailJsonSerializer extends JsonSerializer { + + // 가장 중요한: 표준 필드와 properties를 루트에 직렬화 + @Override + public void serialize(ProblemDetail pd, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + + // type/title/detail/instance는 null 가능 + gen.writeStringField("type", toString(pd.getType())); + if (pd.getTitle() != null) gen.writeStringField("title", pd.getTitle()); + gen.writeNumberField("status", pd.getStatus()); + if (pd.getDetail() != null) { + gen.writeStringField("detail", pd.getDetail()); + gen.writeStringField("message", pd.getDetail()); + } + if (pd.getInstance() != null) gen.writeStringField("instance", toString(pd.getInstance())); + + // (가장 많이 사용하는) 확장 속성 properties를 루트로 플래튼 + for (Map.Entry e : pd.getProperties().entrySet()) { + Object v = e.getValue(); + if (v != null) gen.writeObjectField(e.getKey(), v); + } + gen.writeEndObject(); + } + + // 한 줄 요약: URI를 문자열로 변환 + private static String toString(URI uri) { return uri.toString(); } +} diff --git a/back/src/main/java/com/back/global/problemdetail/ProblemDetails.java b/back/src/main/java/com/back/global/problemdetail/ProblemDetails.java new file mode 100644 index 0000000..5938937 --- /dev/null +++ b/back/src/main/java/com/back/global/problemdetail/ProblemDetails.java @@ -0,0 +1,65 @@ +/** + * ProblemDetails + * - ErrorCode/HttpStatus 기반으로 RFC7807 ProblemDetail을 손쉽게 생성하는 유틸 + * - instance/path/timestamp/code/traceId 등의 공통 속성 자동 세팅 + * - 오버로드로 detail/추가 속성(Map) 주입 가능 + */ +package com.back.global.problemdetail; + +import com.back.global.exception.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; + +import java.net.URI; +import java.time.OffsetDateTime; +import java.util.Map; + +public final class ProblemDetails { + + private ProblemDetails() {} + + // 가장 많이 쓰는 생성기(핵심): ErrorCode + (선택)detail + public static ProblemDetail of(ErrorCode code, HttpServletRequest req, Object... args) { + // 메시지 서식 적용 + String detail = (args == null || args.length == 0) + ? code.getMessage() + : String.format(code.getMessage(), args); + + // ProblemDetail 생성 + 공통 속성 세팅 + ProblemDetail pd = ProblemDetail.forStatusAndDetail(code.getStatus(), detail); + pd.setTitle(code.name()); + pd.setType(URI.create("about:blank")); + setCommonProps(pd, req, code.getCode()); + return pd; + } + + // HttpStatus/제목/상세/코드/추가속성 버전 + public static ProblemDetail of(HttpStatus status, + String title, + String detail, + String code, + Map properties, + HttpServletRequest req) { + ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail); + pd.setTitle(title); + pd.setType(URI.create("about:blank")); + setCommonProps(pd, req, code); + if (properties != null) properties.forEach(pd::setProperty); + return pd; + } + + // 공통 속성 세팅(가장 많이 호출됨) + private static void setCommonProps(ProblemDetail pd, HttpServletRequest req, String code) { + // 가장 중요한 공통 속성 한 줄 요약: path/instance/timestamp/code/traceId를 세팅 + pd.setProperty("timestamp", OffsetDateTime.now().toString()); + if (code != null) pd.setProperty("code", code); + if (req != null) { + pd.setInstance(URI.create(req.getRequestURI())); + pd.setProperty("path", req.getRequestURI()); + } + String traceId = MDC.get("traceId"); + if (traceId != null) pd.setProperty("traceId", traceId); + } +} 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 622b179..5e2ade8 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 @@ -1,12 +1,16 @@ /** - * [TEST] BaseLine/BaseNode 저장·조회 (글로벌 에러 응답 검증 포함) - * - 성공: bulk 저장, 라인 조회, 피벗 조회 - * - 실패: bulk 저장 유효성 실패(nodes < 2) → ApiException(INVALID_INPUT_VALUE) 검증 + * [TEST SUITE] Re:Life — BaseLine/BaseNode 통합 테스트 (분류별 정리) + * + * 목적 + * - 베이스 라인/노드 저장·조회, 피벗 규칙, 검증 에러, 존재하지 않는 자원 404, 트랜잭션 롤백, 정렬 안정성 통합 검증 + * */ package com.back.domain.node.controller; import com.back.domain.node.dto.BaseLineBulkCreateResponse; import com.back.domain.node.entity.NodeCategory; +import com.back.domain.node.repository.BaseLineRepository; +import com.back.domain.node.repository.BaseNodeRepository; import com.back.domain.user.entity.*; import com.back.domain.user.repository.UserRepository; import com.fasterxml.jackson.databind.JsonNode; @@ -28,13 +32,16 @@ @SpringBootTest @AutoConfigureMockMvc(addFilters = false) @TestInstance(TestInstance.Lifecycle.PER_CLASS) -@DisplayName("BaseLine 플로우(저장/조회) - 통합 테스트(에러포맷 포함)") +@DisplayName("Re:Life — BaseLine/BaseNode 통합 테스트") public class BaseLineControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper om; @Autowired private UserRepository userRepository; + @Autowired private BaseLineRepository baseLineRepository; + @Autowired private BaseNodeRepository baseNodeRepository; + private Long userId; @BeforeAll @@ -54,13 +61,16 @@ void initUser() { userId = userRepository.save(user).getId(); } + // =========================== + // 베이스 라인 생성/검증 + // =========================== @Nested - @DisplayName("베이스 라인 일괄 저장") - class BulkCreate { + @DisplayName("베이스 라인 생성/검증") + class BaseLine_Create_Validate { @Test - @DisplayName("T1: 헤더~꼬리 4개 노드 라인을 일괄 저장하면 201과 생성 id 목록을 돌려준다") - void t1_bulkCreateLine_success() throws Exception { + @DisplayName("성공 : 헤더~꼬리 4개 노드 라인을 /bulk로 저장하면 201과 생성 id 목록을 반환한다") + void success_bulkCreateLine() throws Exception { String payload = sampleLineJson(userId); var res = mockMvc.perform(post("/api/v1/base-lines/bulk") @@ -78,29 +88,96 @@ void t1_bulkCreateLine_success() throws Exception { } @Test - @DisplayName("T1-ERR: nodes 길이가 2 미만이면 400/INVALID_INPUT_VALUE 반환") - void t1_err_bulkCreateLine_invalidNodes() throws Exception { + @DisplayName("실패 : nodes 길이가 2 미만이면 400/C001을 반환한다") + void fail_nodesTooShort() throws Exception { + // decision 필수 → 단건 샘플에도 decision 채움 String bad = """ - { "userId": %d, "nodes": [ { "category":"%s", "situation":"단건", "ageYear":18 } ] } + { "userId": %d, "nodes": [ { "category":"%s", "situation":"단건", "decision":"단건", "ageYear":18 } ] } """.formatted(userId, NodeCategory.EDUCATION); mockMvc.perform(post("/api/v1/base-lines/bulk") .contentType(MediaType.APPLICATION_JSON) .content(bad)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.status").value(400)) - .andExpect(jsonPath("$.code").value("C001")) - .andExpect(jsonPath("$.message").exists()); + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + @DisplayName("실패 : 음수 ageYear가 포함되면 400/C001을 반환한다") + void fail_negativeAge() throws Exception { + String bad = """ + { "userId": %d, + "nodes": [ + {"category":"%s","situation":"음수나이","decision":"음수나이","ageYear":-1}, + {"category":"%s","situation":"꼬리","decision":"꼬리","ageYear":24} + ] + } + """.formatted(userId, NodeCategory.EDUCATION, NodeCategory.ETC); + + mockMvc.perform(post("/api/v1/base-lines/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(bad)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + @DisplayName("실패 : situation 문자열 길이 초과 시 400/C001을 반환한다") + void fail_longText() throws Exception { + String longText = "가".repeat(5000); + String bad = """ + { "userId": %d, + "nodes": [ + {"category":"%s","situation":"%s","decision":"%s","ageYear":18}, + {"category":"%s","situation":"정상","decision":"정상","ageYear":24} + ] + } + """.formatted(userId, NodeCategory.EDUCATION, longText, longText, NodeCategory.ETC); + + mockMvc.perform(post("/api/v1/base-lines/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(bad)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + @DisplayName("실패 : 중간 노드 invalid 시 트랜잭션 롤백되어 어떤 엔티티도 남지 않는다") + void fail_midInvalid_rollback() throws Exception { + long beforeLines = baseLineRepository.count(); + long beforeNodes = baseNodeRepository.count(); + + String midBad = """ + { "userId": %d, + "nodes": [ + {"category":"%s","situation":"헤더","decision":"헤더","ageYear":18}, + {"category":"%s","situation":"중간-invalid","decision":"중간-invalid","ageYear":-1}, + {"category":"%s","situation":"꼬리","decision":"꼬리","ageYear":24} + ] + } + """.formatted(userId, NodeCategory.EDUCATION, NodeCategory.CAREER, NodeCategory.ETC); + + mockMvc.perform(post("/api/v1/base-lines/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(midBad)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + + assertThat(baseLineRepository.count()).isEqualTo(beforeLines); + assertThat(baseNodeRepository.count()).isEqualTo(beforeNodes); } } + // =========================== + // 베이스 라인 조회 + // =========================== @Nested @DisplayName("베이스 라인 조회") - class ReadLine { + class BaseLine_Read { @Test - @DisplayName("T2: 저장된 라인을 ageYear 오름차순으로 조회할 수 있다") - void t2_readLine_sortedByAge() throws Exception { + @DisplayName("성공 : 저장된 라인을 ageYear 오름차순(동률 id ASC)으로 조회할 수 있다") + void success_readLine_sortedByAge() throws Exception { Long baseLineId = saveAndGetBaseLineId(); var res = mockMvc.perform(get("/api/v1/base-lines/{id}/nodes", baseLineId)) @@ -114,40 +191,137 @@ void t2_readLine_sortedByAge() throws Exception { } @Test - @DisplayName("T3: 피벗 목록은 헤더/꼬리 제외한 나이만 반환한다(20,22)") - void t3_readPivots_middleOnly() throws Exception { - Long baseLineId = saveAndGetBaseLineId(); + @DisplayName("실패 : 존재하지 않는 baseLineId로 조회 시 404/N002를 반환한다") + void fail_lineNotFound() throws Exception { + long unknownId = 9_999_999L; + mockMvc.perform(get("/api/v1/base-lines/{id}/nodes", unknownId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("N002")) + .andExpect(jsonPath("$.message").exists()); + } - var res = mockMvc.perform(get("/api/v1/base-lines/{id}/pivots", baseLineId)) + // (가장 중요한) 라인 저장 후 baseLineId 반환 + private Long saveAndGetBaseLineId() throws Exception { + var res = mockMvc.perform(post("/api/v1/base-lines/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(sampleLineJson(userId))) + .andExpect(status().isCreated()) + .andReturn(); + return om.readTree(res.getResponse().getContentAsString()).get("baseLineId").asLong(); + } + } + + // =========================== + // 베이스 노드 조회 + // =========================== + @Nested + @DisplayName("베이스 노드 조회") + class BaseNode_Read { + + @Test + @DisplayName("성공 : /nodes/{baseNodeId} 단건 조회가 정상 동작한다") + void success_readSingleNode() throws Exception { + Long baseLineId = createLineAndGetId(userId); + + var listRes = mockMvc.perform(get("/api/v1/base-lines/{id}/nodes", baseLineId)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.baseLineId").value(baseLineId)) - .andExpect(jsonPath("$.pivots.length()").value(2)) .andReturn(); + var arr = om.readTree(listRes.getResponse().getContentAsString()); + long nodeId = arr.get(0).get("id").asLong(); - JsonNode pivots = om.readTree(res.getResponse().getContentAsString()).get("pivots"); - assertThat(pivots.get(0).get("ageYear").asInt()).isEqualTo(20); - assertThat(pivots.get(1).get("ageYear").asInt()).isEqualTo(22); + mockMvc.perform(get("/api/v1/base-lines/nodes/{nodeId}", nodeId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(nodeId)) + .andExpect(jsonPath("$.baseLineId").value(baseLineId)); } - // 저장 → baseLineId 반환 - private Long saveAndGetBaseLineId() throws Exception { + @Test + @DisplayName("실패 : 존재하지 않는 baseNodeId 단건 조회 시 404/N001을 반환한다") + void fail_nodeNotFound() throws Exception { + long unknownNode = 9_999_999L; + mockMvc.perform(get("/api/v1/base-lines/nodes/{nodeId}", unknownNode)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("N001")) + .andExpect(jsonPath("$.message").exists()); + } + + // (자주 쓰는) 뒤섞인 입력으로 라인 생성 후 baseLineId 반환(정렬 안정성 검증 겸용) + private Long createLineAndGetId(Long uid) throws Exception { var res = mockMvc.perform(post("/api/v1/base-lines/bulk") .contentType(MediaType.APPLICATION_JSON) - .content(sampleLineJson(userId))) + .content(sampleShuffledJson(uid))) .andExpect(status().isCreated()) .andReturn(); return om.readTree(res.getResponse().getContentAsString()).get("baseLineId").asLong(); } + + // (자주 쓰는) 정렬 안정성 검증용 뒤섞인 입력 샘플 생성 + private String sampleShuffledJson(Long uid) { + return """ + { "userId": %d, + "nodes": [ + {"category":"%s","situation":"첫 인턴","decision":"첫 인턴","ageYear":22}, + {"category":"%s","situation":"결말","decision":"결말","ageYear":24}, + {"category":"%s","situation":"고등학교 졸업","decision":"고등학교 졸업","ageYear":18}, + {"category":"%s","situation":"대학 입학","decision":"대학 입학","ageYear":20} + ] + } + """.formatted(uid, + NodeCategory.CAREER, NodeCategory.ETC, NodeCategory.EDUCATION, NodeCategory.CAREER); + } + } + + // =========================== + // 피벗 규칙 검증 + // =========================== + @Nested + @DisplayName("피벗 규칙 검증") + class Pivot_Rules { + + @Test + @DisplayName("성공 : 피벗은 헤더/꼬리 제외, 중복 제거, 오름차순 정렬이 보장된다") + void success_pivotRules() throws Exception { + String withDup = """ + { "userId": %d, + "nodes": [ + {"category":"%s","situation":"헤더","decision":"헤더","ageYear":18}, + {"category":"%s","situation":"중간1","decision":"중간1","ageYear":20}, + {"category":"%s","situation":"중복20","decision":"중복20","ageYear":20}, + {"category":"%s","situation":"중간2","decision":"중간2","ageYear":22}, + {"category":"%s","situation":"꼬리","decision":"꼬리","ageYear":24} + ] + } + """.formatted(userId, + NodeCategory.EDUCATION, NodeCategory.CAREER, NodeCategory.CAREER, NodeCategory.CAREER, NodeCategory.ETC); + + var res = mockMvc.perform(post("/api/v1/base-lines/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(withDup)) + .andExpect(status().isCreated()) + .andReturn(); + + long baseLineId = om.readTree(res.getResponse().getContentAsString()).get("baseLineId").asLong(); + + var pivotsRes = mockMvc.perform(get("/api/v1/base-lines/{id}/pivots", baseLineId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pivots.length()").value(2)) + .andReturn(); + + var pivots = om.readTree(pivotsRes.getResponse().getContentAsString()).get("pivots"); + assertThat(pivots.get(0).get("ageYear").asInt()).isEqualTo(20); + assertThat(pivots.get(1).get("ageYear").asInt()).isEqualTo(22); + } } + // (자주 쓰는) 정상 입력 샘플 JSON 생성 private String sampleLineJson(Long uid) { return """ { "userId": %d, "nodes": [ - {"category":"%s","situation":"고등학교 졸업","decision":null,"ageYear":18}, - {"category":"%s","situation":"대학 입학","decision":null,"ageYear":20}, - {"category":"%s","situation":"첫 인턴","decision":null,"ageYear":22}, - {"category":"%s","situation":"결말","decision":null,"ageYear":24} + {"category":"%s","situation":"고등학교 졸업","decision":"고등학교 졸업","ageYear":18}, + {"category":"%s","situation":"대학 입학","decision":"대학 입학","ageYear":20}, + {"category":"%s","situation":"첫 인턴","decision":"첫 인턴","ageYear":22}, + {"category":"%s","situation":"결말","decision":"결말","ageYear":24} ] } """.formatted(uid, 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 new file mode 100644 index 0000000..92b31a2 --- /dev/null +++ b/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java @@ -0,0 +1,449 @@ +/** + * [TEST SUITE] Re:Life — Decision Flow (from-base · next · cancel · complete) 통합 테스트 + * + * 목적 + * - /api/v1/decision-flow/from-base 에서 첫 결정 생성, /next 에서 연속 결정 생성, /cancel·/complete 상태 전이 검증 + * - 성공/실패(존재하지 않는 자원, 라인 상태, 규칙 위반, 중복 나이, 피벗 불일치) 분류별로 정리 + * + */ +package com.back.domain.node.controller; + +import com.back.domain.node.entity.NodeCategory; +import com.back.domain.user.entity.*; +import com.back.domain.user.repository.UserRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("Re:Life — DecisionFlowController from-base · next · cancel · complete 통합 테스트") +public class DecisionFlowControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper om; + @Autowired private UserRepository userRepository; + + private Long userId; + + @BeforeAll + void initUser() { + String uid = UUID.randomUUID().toString().substring(0, 8); + User user = User.builder() + .loginId("login_" + uid) + .email("user_" + uid + "@test.local") + .role(Role.GUEST) + .birthdayAt(LocalDateTime.now().minusYears(25)) + .gender(Gender.M) + .mbti(Mbti.INTJ) + .beliefs("NONE") + .authProvider(AuthProvider.GUEST) + .nickname("tester-" + uid) + .build(); + userId = userRepository.save(user).getId(); + } + + // =========================== + // 첫 결정(from-base) + // =========================== + @Nested + @DisplayName("첫 결정(from-base)") + class FromBase { + + @Test + @DisplayName("성공 : 유효한 피벗에서 options[2]와 selectedAltIndex로 첫 결정을 생성하면 201과 DecLineDto를 반환한다") + void success_createFromBase() throws Exception { + var baseInfo = createBaseLineAndPickFirstPivot(userId); + + String req = """ + { + "userId": %d, + "baseLineId": %d, + "pivotAge": %d, + "selectedAltIndex": 1, + "category": "%s", + "situation": "대학 전공 선택", + "options": ["전과", "휴학"], + "selectedIndex": 1 + } + """.formatted(userId, baseInfo.baseLineId, baseInfo.pivotAge, NodeCategory.EDUCATION); + + var res = mockMvc.perform(post("/api/v1/decision-flow/from-base") + .contentType(MediaType.APPLICATION_JSON) + .content(req)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.type").value("DECISION")) + .andExpect(jsonPath("$.decisionLineId").exists()) + .andExpect(jsonPath("$.baseNodeId").isNumber()) + .andExpect(jsonPath("$.decision").value("휴학")) + .andReturn(); + + JsonNode body = om.readTree(res.getResponse().getContentAsString()); + assertThat(body.get("ageYear").asInt()).isEqualTo(baseInfo.pivotAge); + } + + @Test + @DisplayName("실패 : 존재하지 않는 베이스라인으로 from-base 요청 시 404/N002를 반환한다") + void fail_baseLineNotFound_onFromBase() throws Exception { + var baseInfo = createBaseLineAndPickFirstPivot(userId); + String req = """ + { + "userId": %d, + "baseLineId": 9999999, + "pivotAge": %d, + "selectedAltIndex": 0, + "category": "%s", + "situation": "무시", + "options": ["옵션1","옵션2"], + "selectedIndex": 0 + } + """.formatted(userId, baseInfo.pivotAge, NodeCategory.RELATIONSHIP); + + mockMvc.perform(post("/api/v1/decision-flow/from-base") + .contentType(MediaType.APPLICATION_JSON) + .content(req)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("N002")) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @DisplayName("실패 : 잘못된 pivotAge 또는 pivotOrd(범위 밖)면 400/C001을 반환한다") + void fail_invalidPivot_onFromBase() throws Exception { + var baseInfo = createBaseLineAndPickFirstPivot(userId); + String bad = """ + { + "userId": %d, + "baseLineId": %d, + "pivotAge": %d, + "selectedAltIndex": 0, + "category": "%s", + "situation": "무시", + "options": ["A","B"], + "selectedIndex": 0 + } + """.formatted(userId, baseInfo.baseLineId, baseInfo.pivotAge + 99, NodeCategory.ETC); + + mockMvc.perform(post("/api/v1/decision-flow/from-base") + .contentType(MediaType.APPLICATION_JSON) + .content(bad)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + @DisplayName("실패 : 동일 분기 슬롯을 두 번 링크하려 하면 400/C001(이미 링크됨)을 반환한다") + void fail_branchSlotAlreadyLinked() throws Exception { + var baseInfo = createBaseLineAndPickFirstPivot(userId); + + String first = """ + { + "userId": %d, + "baseLineId": %d, + "pivotAge": %d, + "selectedAltIndex": 0, + "category": "%s", + "situation": "첫 분기", + "options": ["A","B"], + "selectedIndex": 0 + } + """.formatted(userId, baseInfo.baseLineId, baseInfo.pivotAge, NodeCategory.CAREER); + + mockMvc.perform(post("/api/v1/decision-flow/from-base") + .contentType(MediaType.APPLICATION_JSON) + .content(first)) + .andExpect(status().isCreated()); + + String secondSameSlot = first.replace("\"첫 분기\"", "\"중복 분기\""); + mockMvc.perform(post("/api/v1/decision-flow/from-base") + .contentType(MediaType.APPLICATION_JSON) + .content(secondSameSlot)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + } + + // =========================== + // 다음 결정(next) + // =========================== + @Nested + @DisplayName("다음 결정(next)") + class NextDecision { + + @Test + @DisplayName("성공 : 부모에서 다음 피벗 나이로 생성하면 201과 DecLineDto(부모 id/다음 나이)를 반환한다") + void success_createNextDecision() throws Exception { + var head = startDecisionFromBase(userId); + long parentId = head.decisionNodeId; + + String nextReq = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "category": "%s", + "situation": "인턴 후 진로 기로", + "options": ["수락","보류","거절"], + "selectedIndex": 0 + } + """.formatted(userId, parentId, NodeCategory.CAREER); + + var res = mockMvc.perform(post("/api/v1/decision-flow/next") + .contentType(MediaType.APPLICATION_JSON) + .content(nextReq)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.parentId").value(parentId)) + .andReturn(); + + JsonNode body = om.readTree(res.getResponse().getContentAsString()); + assertThat(body.get("ageYear").asInt()).isGreaterThan(head.ageYear); + assertThat(body.get("decision").asText()).isEqualTo("수락"); + } + + @Test + @DisplayName("실패 : 존재하지 않는 부모 결정 노드로 next 요청 시 404/N001을 반환한다") + void fail_parentDecisionNotFound() throws Exception { + String nextReq = """ + { + "userId": %d, + "parentDecisionNodeId": 9999999, + "category": "%s", + "situation": "무시", + "options": ["x","y"], + "selectedIndex": 0 + } + """.formatted(userId, NodeCategory.CAREER); + + mockMvc.perform(post("/api/v1/decision-flow/next") + .contentType(MediaType.APPLICATION_JSON) + .content(nextReq)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("N001")) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @DisplayName("실패 : 동일 나이로 재생성 시도(부모 ageYear와 같음)면 400/C001을 반환한다") + void fail_duplicateAgeOnLine() throws Exception { + var head = startDecisionFromBase(userId); + + String nextReq = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "category": "%s", + "situation": "동일나이 재결정", + "ageYear": %d + } + """.formatted(userId, head.decisionNodeId, NodeCategory.ETC, head.ageYear); + + mockMvc.perform(post("/api/v1/decision-flow/next") + .contentType(MediaType.APPLICATION_JSON) + .content(nextReq)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + @DisplayName("실패 : 부모 결정 나이보다 작은 나이로 next 요청 시 400/C001을 반환한다") + void fail_nextAgeLessThanParent() throws Exception { + var head = startDecisionFromBase(userId); + int invalidAge = head.ageYear - 1; + + String nextReq = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "category": "%s", + "situation": "나이 감소", + "ageYear": %d + } + """.formatted(userId, head.decisionNodeId, NodeCategory.ETC, invalidAge); + + mockMvc.perform(post("/api/v1/decision-flow/next") + .contentType(MediaType.APPLICATION_JSON) + .content(nextReq)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + } + + // =========================== + // 상태 전이(cancel / complete) + // =========================== + @Nested + @DisplayName("상태 전이(cancel/complete)") + class Lifecycle { + + @Test + @DisplayName("성공 : 취소 요청 시 라인 상태가 CANCELLED로 바뀐다") + void success_cancel() throws Exception { + var head = startDecisionFromBase(userId); + mockMvc.perform(post("/api/v1/decision-flow/{decisionLineId}/cancel", head.decisionLineId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.decisionLineId").value(head.decisionLineId)) + .andExpect(jsonPath("$.status").value("CANCELLED")); + } + + @Test + @DisplayName("성공 : 완료 요청 시 라인 상태가 COMPLETED로 바뀐다") + void success_complete() throws Exception { + var head = startDecisionFromBase(userId); + mockMvc.perform(post("/api/v1/decision-flow/{decisionLineId}/complete", head.decisionLineId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.decisionLineId").value(head.decisionLineId)) + .andExpect(jsonPath("$.status").value("COMPLETED")); + } + + @Test + @DisplayName("실패 : 존재하지 않는 decisionLineId 취소/완료 시 404/N003를 반환한다") + void fail_lineNotFound_onLifecycle() throws Exception { + mockMvc.perform(post("/api/v1/decision-flow/{decisionLineId}/cancel", 9999999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("N003")); + mockMvc.perform(post("/api/v1/decision-flow/{decisionLineId}/complete", 9999999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("N003")); + } + + @Test + @DisplayName("실패 : 완료/취소된 라인에서 next 시도 시 400/C001(line is locked)을 반환한다") + void fail_nextAfterLocked() throws Exception { + var head = startDecisionFromBase(userId); + mockMvc.perform(post("/api/v1/decision-flow/{decisionLineId}/complete", head.decisionLineId)) + .andExpect(status().isOk()); + + String nextReq = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "category": "%s", + "situation": "완료 후 시도", + "options": ["x","y"], + "selectedIndex": 0 + } + """.formatted(userId, head.decisionNodeId, NodeCategory.CAREER); + + mockMvc.perform(post("/api/v1/decision-flow/next") + .contentType(MediaType.APPLICATION_JSON) + .content(nextReq)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + + var head2 = startDecisionFromBase(userId); + mockMvc.perform(post("/api/v1/decision-flow/{decisionLineId}/cancel", head2.decisionLineId)) + .andExpect(status().isOk()); + + String nextReq2 = """ + { + "userId": %d, + "parentDecisionNodeId": %d, + "category": "%s", + "situation": "취소 후 시도" + } + """.formatted(userId, head2.decisionNodeId, NodeCategory.ETC); + + mockMvc.perform(post("/api/v1/decision-flow/next") + .contentType(MediaType.APPLICATION_JSON) + .content(nextReq2)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + } + + // =========================== + // 공통 헬퍼 + // =========================== + + // 베이스라인 생성 → 첫 피벗에서 options[2] 입력/선택으로 결정 시작 → 헤드 결정 정보 반환 + private HeadDecision startDecisionFromBase(Long uid) throws Exception { + var createRes = mockMvc.perform(post("/api/v1/base-lines/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(sampleLineJson(uid))) + .andExpect(status().isCreated()) + .andReturn(); + JsonNode created = om.readTree(createRes.getResponse().getContentAsString()); + long baseLineId = created.get("baseLineId").asLong(); + + var pivotsRes = mockMvc.perform(get("/api/v1/base-lines/{id}/pivots", baseLineId)) + .andExpect(status().isOk()) + .andReturn(); + JsonNode pivots = om.readTree(pivotsRes.getResponse().getContentAsString()).get("pivots"); + int pivotAge = pivots.get(0).get("ageYear").asInt(); + + String fromBaseReq = """ + { + "userId": %d, + "baseLineId": %d, + "pivotAge": %d, + "selectedAltIndex": 0, + "category": "%s", + "situation": "피벗에서 첫 결정", + "options": ["선택 A","선택 B"], + "selectedIndex": 0 + } + """.formatted(uid, baseLineId, pivotAge, NodeCategory.EDUCATION); + + var fromBaseRes = mockMvc.perform(post("/api/v1/decision-flow/from-base") + .contentType(MediaType.APPLICATION_JSON) + .content(fromBaseReq)) + .andExpect(status().isCreated()) + .andReturn(); + JsonNode head = om.readTree(fromBaseRes.getResponse().getContentAsString()); + long decisionLineId = head.get("decisionLineId").asLong(); + long decisionNodeId = head.get("id").asLong(); + int headAge = head.get("ageYear").asInt(); + + return new HeadDecision(decisionLineId, decisionNodeId, headAge); + } + + // 베이스라인을 만들고 첫 피벗(age) 정보를 반환 + private BaseInfo createBaseLineAndPickFirstPivot(Long uid) throws Exception { + var res = mockMvc.perform(post("/api/v1/base-lines/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(sampleLineJson(uid))) + .andExpect(status().isCreated()) + .andReturn(); + long baseLineId = om.readTree(res.getResponse().getContentAsString()).get("baseLineId").asLong(); + + var pivotsRes = mockMvc.perform(get("/api/v1/base-lines/{id}/pivots", baseLineId)) + .andExpect(status().isOk()) + .andReturn(); + JsonNode pivots = om.readTree(pivotsRes.getResponse().getContentAsString()).get("pivots"); + int pivotAge = pivots.get(0).get("ageYear").asInt(); + return new BaseInfo(baseLineId, pivotAge); + } + + // 정상 입력 샘플 JSON — 헤더/중간/중간/꼬리 4노드 (fixedChoice=decision을 채워 유효성 통과) + private String sampleLineJson(Long uid) { + return """ + { "userId": %d, + "nodes": [ + {"category":"%s","situation":"고등학교 졸업","decision":"고등학교 졸업","ageYear":18}, + {"category":"%s","situation":"대학 입학","decision":"대학 입학","ageYear":20}, + {"category":"%s","situation":"첫 인턴","decision":"첫 인턴","ageYear":22}, + {"category":"%s","situation":"결말","decision":"결말","ageYear":24} + ] + } + """.formatted(uid, + NodeCategory.EDUCATION, NodeCategory.EDUCATION, NodeCategory.CAREER, NodeCategory.ETC); + } + + // 헤드 결정 정보 DTO + private record HeadDecision(long decisionLineId, long decisionNodeId, int ageYear) {} + + // 피벗 정보 DTO + private record BaseInfo(long baseLineId, int pivotAge) {} +}