Skip to content

Commit ff275e2

Browse files
[Feat]: 시나리오 AI 연동 Global 유틸리티 작성 (#50)
* [Feat] AI Service 설계 * [Refactor] 변경된 설계구조 반영 * gemini text client 구현 * [Feat] AI 프롬프트 로직 작성 * [Refactor] 선택상황생성프롬프트 로직 보강 * [Feat] AiServiceImpl ã작성 * [Refactor] Global AI Javadoc 추가 * [Refactor] Scenario Entity BaseLine 컬럼 추가 * [Refactor] Timeline Title 로직 변경 * [Feat] ScenarioService AI 연동 * [Feat] 중복 ì²생성 방지 및 재시도 로직 구현 * [Refactor] 동시성 문제 해결 * [Refactor] ApiResponse -> ResponseEntity 변í경 * [Refactor] Service에서 트랜잭션 분리 * [Refactor] Controller ì�사용자검증적용 * [Refactor] 프롬프트 수정 및 베이스라인목록조회구현 * [Refactor] DTO from메서드 ì추가 및 aiserviceimpltest 추가 * [Test] Test 리팩토링 * [Comment] 주석 수정 * [Comment] 주석 수정 * [Refactor] PR 리뷰 반영 jwt 삭제
1 parent e0bff9a commit ff275e2

39 files changed

+2700
-422
lines changed

back/build.gradle.kts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import org.gradle.kotlin.dsl.annotationProcessor
2-
import org.gradle.kotlin.dsl.testAnnotationProcessor
3-
41
plugins {
52
java
63
id("org.springframework.boot") version "3.5.5"
@@ -33,8 +30,10 @@ dependencies {
3330
implementation("org.springframework.boot:spring-boot-starter-validation")
3431
implementation("org.springframework.boot:spring-boot-starter-web")
3532
implementation("org.springframework.boot:spring-boot-starter-oauth2-client") // OAuth2 Client 추가
33+
3634
// Swagger
3735
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
36+
3837
// JWT
3938
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
4039
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
@@ -54,8 +53,13 @@ dependencies {
5453
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
5554
testAnnotationProcessor("io.github.openfeign.querydsl:querydsl-apt:7.0:jpa")
5655
testAnnotationProcessor("jakarta.persistence:jakarta.persistence-api")
56+
57+
// AI Services - WebFlux for non-blocking HTTP clients
58+
implementation("org.springframework.boot:spring-boot-starter-webflux")
59+
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
5760
}
5861

5962
tasks.withType<Test> {
6063
useJUnitPlatform()
6164
}
65+

back/src/main/java/com/back/domain/node/entity/BaseLine.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
@Entity
1818
@Table(name = "base_lines")
1919
@Getter
20+
@Setter
2021
@NoArgsConstructor(access = AccessLevel.PROTECTED)
2122
@AllArgsConstructor
2223
@Builder

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
import com.back.domain.node.entity.BaseLine;
44
import com.back.domain.user.entity.User;
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.Pageable;
57
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
610
import org.springframework.stereotype.Repository;
711

812
import java.util.List;
@@ -22,4 +26,8 @@ public interface BaseLineRepository extends JpaRepository<BaseLine, Long> {
2226
List<BaseLine> findByUser_IdOrderByIdDesc(Long userId);
2327

2428
boolean existsByUserAndTitle(User user, String title);
29+
30+
// 사용자별 베이스라인 목록 조회 (페이징 및 N+1 방지)
31+
@Query("SELECT DISTINCT bl FROM BaseLine bl LEFT JOIN FETCH bl.baseNodes bn WHERE bl.user.id = :userId ORDER BY bl.createdDate DESC")
32+
Page<BaseLine> findAllByUserIdWithBaseNodes(@Param("userId") Long userId, Pageable pageable);
2533
}

back/src/main/java/com/back/domain/scenario/controller/ScenarioController.java

Lines changed: 50 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@
22

33
import com.back.domain.scenario.dto.*;
44
import com.back.domain.scenario.service.ScenarioService;
5-
import com.back.global.common.ApiResponse;
6-
import com.back.global.exception.ApiException;
7-
import com.back.global.exception.ErrorCode;
5+
import com.back.global.security.CustomUserDetails;
86
import io.swagger.v3.oas.annotations.Operation;
97
import io.swagger.v3.oas.annotations.Parameter;
108
import io.swagger.v3.oas.annotations.tags.Tag;
119
import jakarta.validation.Valid;
1210
import lombok.RequiredArgsConstructor;
11+
import org.springframework.data.domain.Page;
12+
import org.springframework.data.domain.Pageable;
1313
import org.springframework.http.HttpStatus;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1416
import org.springframework.web.bind.annotation.*;
1517

16-
import java.security.Principal;
17-
import java.util.List;
18-
1918
/**
2019
* 시나리오 관련 API 요청을 처리하는 컨트롤러.
2120
* 시나리오 추출, 상세 조회, 비교 등의 기능을 제공합니다.
@@ -26,90 +25,96 @@
2625
@RequiredArgsConstructor
2726
public class ScenarioController {
2827

29-
// TODO: ApiResponse를 ResponseEntity로 변경 예정
3028
private final ScenarioService scenarioService;
3129

30+
/**
31+
* 인증된 사용자의 ID를 안전하게 추출합니다.
32+
* 테스트 환경에서 userDetails가 null일 수 있으므로 기본값을 제공합니다.
33+
*/
34+
private Long getUserId(CustomUserDetails userDetails) {
35+
if (userDetails == null || userDetails.getUser() == null) {
36+
// 테스트 환경이나 인증이 비활성화된 환경에서는 기본 사용자 ID 사용
37+
return 1L;
38+
}
39+
return userDetails.getUser().getId();
40+
}
41+
3242
@PostMapping
3343
@Operation(summary = "시나리오 생성", description = "DecisionLine을 기반으로 AI 시나리오를 생성합니다.")
34-
public ApiResponse<ScenarioStatusResponse> createScenario(
35-
@Valid @RequestBody ScenarioCreateRequest request
44+
public ResponseEntity<ScenarioStatusResponse> createScenario(
45+
@Valid @RequestBody ScenarioCreateRequest request,
46+
@AuthenticationPrincipal CustomUserDetails userDetails
3647
) {
37-
Long userId = 1L; // TODO: Principal에서 추출 예정
48+
Long userId = getUserId(userDetails);
3849

3950
ScenarioStatusResponse scenarioCreateResponse = scenarioService.createScenario(userId, request);
4051

41-
return ApiResponse.success(scenarioCreateResponse, "시나리오 생성 요청이 접수되었습니다.", HttpStatus.CREATED);
52+
return ResponseEntity.status(HttpStatus.CREATED).body(scenarioCreateResponse);
4253
}
4354

4455
@GetMapping("/{scenarioId}/status")
4556
@Operation(summary = "시나리오 상태 조회", description = "시나리오 생성 진행 상태를 조회합니다.")
46-
public ApiResponse<ScenarioStatusResponse> getScenarioStatus(
47-
@Parameter(description = "시나리오 ID") @PathVariable Long scenarioId
57+
public ResponseEntity<ScenarioStatusResponse> getScenarioStatus(
58+
@Parameter(description = "시나리오 ID") @PathVariable Long scenarioId,
59+
@AuthenticationPrincipal CustomUserDetails userDetails
4860
) {
49-
Long userId = 1L; // TODO: Principal에서 추출 예정
61+
Long userId = getUserId(userDetails);
5062

5163
ScenarioStatusResponse scenarioStatusResponse = scenarioService.getScenarioStatus(scenarioId, userId);
5264

53-
return ApiResponse.success(scenarioStatusResponse, "상태를 성공적으로 조회했습니다.");
65+
return ResponseEntity.ok(scenarioStatusResponse);
5466
}
5567

5668
@GetMapping("/info/{scenarioId}")
5769
@Operation(summary = "시나리오 상세 조회", description = "완성된 시나리오의 상세 정보를 조회합니다.")
58-
public ApiResponse<ScenarioDetailResponse> getScenarioDetail(
59-
@Parameter(description = "시나리오 ID") @PathVariable Long scenarioId
70+
public ResponseEntity<ScenarioDetailResponse> getScenarioDetail(
71+
@Parameter(description = "시나리오 ID") @PathVariable Long scenarioId,
72+
@AuthenticationPrincipal CustomUserDetails userDetails
6073
) {
61-
Long userId = 1L; // TODO: Principal에서 추출 예정
74+
Long userId = getUserId(userDetails);
6275

6376
ScenarioDetailResponse scenarioDetailResponse = scenarioService.getScenarioDetail(scenarioId, userId);
6477

65-
return ApiResponse.success(scenarioDetailResponse, "시나리오 상세 정보를 성공적으로 조회했습니다.");
78+
return ResponseEntity.ok(scenarioDetailResponse);
6679
}
6780

6881
@GetMapping("/{scenarioId}/timeline")
6982
@Operation(summary = "시나리오 타임라인 조회", description = "시나리오의 선택 경로를 시간순으로 조회합니다.")
70-
public ApiResponse<TimelineResponse> getScenarioTimeline(
71-
@Parameter(description = "시나리오 ID") @PathVariable Long scenarioId
83+
public ResponseEntity<TimelineResponse> getScenarioTimeline(
84+
@Parameter(description = "시나리오 ID") @PathVariable Long scenarioId,
85+
@AuthenticationPrincipal CustomUserDetails userDetails
7286
) {
73-
Long userId = 1L; // TODO: Principal에서 추출 예정
87+
Long userId = getUserId(userDetails);
7488

7589
TimelineResponse timelineResponse = scenarioService.getScenarioTimeline(scenarioId, userId);
7690

77-
return ApiResponse.success(timelineResponse, "타임라인을 성공적으로 조회했습니다.");
91+
return ResponseEntity.ok(timelineResponse);
7892
}
7993

8094
@GetMapping("/baselines")
81-
@Operation(summary = "베이스라인 목록 조회", description = "사용자의 베이스라인 목록을 조회합니다.")
82-
public ApiResponse<List<BaselineListResponse>> getBaselines() {
83-
Long userId = 1L; // TODO: Principal에서 추출 예정
95+
@Operation(summary = "베이스라인 목록 조회", description = "사용자의 베이스라인 목록을 페이지네이션으로 조회합니다.")
96+
public ResponseEntity<Page<BaselineListResponse>> getBaselines(
97+
@AuthenticationPrincipal CustomUserDetails userDetails,
98+
Pageable pageable
99+
) {
100+
Long userId = getUserId(userDetails);
84101

85-
List<BaselineListResponse> baselines = scenarioService.getBaselines(userId);
102+
Page<BaselineListResponse> baselines = scenarioService.getBaselines(userId, pageable);
86103

87-
return ApiResponse.success(baselines, "베이스라인 목록을 성공적으로 조회했습니다.");
104+
return ResponseEntity.ok(baselines);
88105
}
89106

90107
@GetMapping("/compare/{baseId}/{compareId}")
91108
@Operation(summary = "시나리오 비교 분석 결과 조회", description = "두 시나리오를 비교 분석 결과를 조회합니다.")
92-
public ApiResponse<ScenarioCompareResponse> compareScenarios(
109+
public ResponseEntity<ScenarioCompareResponse> compareScenarios(
93110
@Parameter(description = "기준 시나리오 ID") @PathVariable Long baseId,
94-
@Parameter(description = "비교 시나리오 ID") @PathVariable Long compareId
111+
@Parameter(description = "비교 시나리오 ID") @PathVariable Long compareId,
112+
@AuthenticationPrincipal CustomUserDetails userDetails
95113
) {
96-
Long userId = 1L; // TODO: Principal에서 추출 예정
114+
Long userId = getUserId(userDetails);
97115

98116
ScenarioCompareResponse scenarioCompareResponse = scenarioService.compareScenarios(baseId, compareId, userId);
99117

100-
return ApiResponse.success(scenarioCompareResponse, "시나리오 비교를 성공적으로 조회했습니다.");
101-
}
102-
103-
// Principal에서 userId 추출하는 Helper 메서드 (Mock 구현)
104-
// TODO: JWT 파싱으로 교체 예정
105-
private Long getUserIdFromPrincipal(Principal principal) {
106-
if (principal == null || principal.getName() == null) {
107-
throw new ApiException(ErrorCode.HANDLE_ACCESS_DENIED);
108-
}
109-
// TODO: 실제로는 JWT 토큰에서 userId 추출
110-
// String token = principal.getName();
111-
// return jwtProvider.getUserIdFromToken(token);
112-
113-
return 1L; // Mock userId
118+
return ResponseEntity.ok(scenarioCompareResponse);
114119
}
115120
}

back/src/main/java/com/back/domain/scenario/dto/BaselineListResponse.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@ public record BaselineListResponse(
3131
* @return BaselineListResponse
3232
*/
3333

34-
// TODO: 구현 필요
3534
public static BaselineListResponse from(BaseLine baseLine, List<String> tags) {
36-
throw new UnsupportedOperationException("구현 예정");
35+
return new BaselineListResponse(
36+
baseLine.getId(),
37+
baseLine.getTitle() != null ? baseLine.getTitle() : "제목 없음",
38+
tags != null ? tags : List.of(),
39+
baseLine.getCreatedDate()
40+
);
3741
}
3842
}

back/src/main/java/com/back/domain/scenario/dto/ScenarioDetailResponse.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.back.domain.scenario.dto;
22

3+
import com.back.domain.scenario.entity.Scenario;
4+
import com.back.domain.scenario.entity.SceneType;
35
import com.back.domain.scenario.entity.ScenarioStatus;
46
import io.swagger.v3.oas.annotations.media.Schema;
57
import java.time.LocalDateTime;
@@ -38,4 +40,21 @@ public record ScenarioDetailResponse(
3840
@Schema(description = "시나리오 결과 지표 정보", example = "[{\"type\":\"경제\",\"point\":85,\"analysis\":\"안정적인 연구직 수입\"}, {\"type\":\"행복\",\"point\":90,\"analysis\":\"의미 있는 일을 통한 성취감\"}]")
3941
List<ScenarioTypeDto> indicators
4042
) {
43+
public static ScenarioDetailResponse from(Scenario scenario, List<SceneType> sceneTypes) {
44+
List<ScenarioTypeDto> indicators = sceneTypes.stream()
45+
.map(st -> new ScenarioTypeDto(st.getType(), st.getPoint(), st.getAnalysis()))
46+
.toList();
47+
48+
return new ScenarioDetailResponse(
49+
scenario.getId(),
50+
scenario.getStatus(),
51+
scenario.getJob(),
52+
scenario.getTotal(),
53+
scenario.getSummary(),
54+
scenario.getDescription(),
55+
scenario.getImg(),
56+
scenario.getCreatedDate(),
57+
indicators
58+
);
59+
}
4160
}

back/src/main/java/com/back/domain/scenario/dto/TimelineResponse.java

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.back.domain.node.entity.DecisionNode;
44
import io.swagger.v3.oas.annotations.media.Schema;
55
import java.util.List;
6+
import java.util.Map;
67

78
/**
89
* 시나리오의 타임라인 정보를 담는 응답 DTO.
@@ -32,8 +33,43 @@ public record TimelineEvent(
3233
* @return TimelineResponse
3334
*/
3435

35-
// TODO: DecisionNode에서 TimelineEvent로 변환하는 로직 구현 필요
36-
public static TimelineResponse from(Long scenarioId, List<DecisionNode> decisionNodes) {
37-
throw new UnsupportedOperationException("구현 예정");
36+
/**
37+
* Scenario의 timelineTitles JSON에서 TimelineResponse를 생성합니다.
38+
* @param scenarioId 시나리오 ID
39+
* @param timelineTitlesJson AI가 생성한 타임라인 제목 JSON
40+
* @return TimelineResponse
41+
*/
42+
public static TimelineResponse fromTimelineTitles(Long scenarioId, String timelineTitlesJson) {
43+
// JSON 파싱은 ScenarioService에서 처리하고 Map으로 전달받는 방식으로 변경
44+
throw new UnsupportedOperationException("fromTimelineTitles 메서드는 ScenarioService에서 직접 구현됨");
45+
}
46+
47+
/**
48+
* 파싱된 타임라인 제목 Map으로부터 TimelineResponse를 생성합니다.
49+
* @param scenarioId 시나리오 ID
50+
* @param timelineTitlesMap 연도별 제목 Map
51+
* @return TimelineResponse
52+
*/
53+
public static TimelineResponse fromTimelineTitlesMap(Long scenarioId, Map<String, String> timelineTitlesMap) {
54+
if (timelineTitlesMap == null || timelineTitlesMap.isEmpty()) {
55+
return new TimelineResponse(scenarioId, List.of());
56+
}
57+
58+
List<TimelineEvent> events = timelineTitlesMap.entrySet().stream()
59+
.map(entry -> {
60+
try {
61+
int year = Integer.parseInt(entry.getKey());
62+
String title = entry.getValue();
63+
return new TimelineEvent(year, title);
64+
} catch (NumberFormatException e) {
65+
// 유효하지 않은 연도는 스킵
66+
return null;
67+
}
68+
})
69+
.filter(event -> event != null)
70+
.sorted((e1, e2) -> Integer.compare(e1.year(), e2.year())) // 연도순 정렬
71+
.toList();
72+
73+
return new TimelineResponse(scenarioId, events);
3874
}
3975
}

back/src/main/java/com/back/domain/scenario/entity/Scenario.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.back.domain.scenario.entity;
22

3+
import com.back.domain.node.entity.BaseLine;
34
import com.back.domain.node.entity.DecisionLine;
45
import com.back.domain.post.entity.Post;
56
import com.back.domain.user.entity.User;
@@ -17,7 +18,7 @@
1718
@Entity
1819
@Table(name = "scenarios")
1920
@Getter
20-
@Setter // TODO:제거 필요한 롬복만 사용, 롬복 동작원리 공부하기
21+
@Setter
2122
@NoArgsConstructor
2223
@AllArgsConstructor
2324
@Builder
@@ -29,10 +30,15 @@ public class Scenario extends BaseEntity {
2930
private User user;
3031

3132
// 시나리오 생성의 기반이 된 선택 경로
32-
@ManyToOne(fetch = FetchType.LAZY)
33-
@JoinColumn(name = "decision_line_id", nullable = false)
33+
@OneToOne(fetch = FetchType.LAZY)
34+
@JoinColumn(name = "decision_line_id", unique = true)
3435
private DecisionLine decisionLine;
3536

37+
// 시나리오 비교 분석 대상 베이스 시나리오 (선택 경로의 베이스라인과 연결)
38+
@OneToOne(fetch = FetchType.LAZY)
39+
@JoinColumn(name = "base_line_id", unique = true)
40+
private BaseLine baseLine;
41+
3642
// 시나리오 처리 상태 (PENDING, PROCESSING, COMPLETED, FAILED)
3743
@Enumerated(EnumType.STRING)
3844
@Column(nullable = false)
@@ -48,7 +54,7 @@ public class Scenario extends BaseEntity {
4854

4955
// 시나리오와 연결된 게시글 (시나리오 공유 시 생성)
5056
@ManyToOne(fetch = FetchType.LAZY)
51-
@JoinColumn(name = "post_id")
57+
@JoinColumn(name = "post_id", unique = true)
5258
private Post post;
5359

5460
// AI가 생성한 직업 정보

back/src/main/java/com/back/domain/scenario/entity/SceneCompare.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import lombok.Builder;
77
import lombok.Getter;
88
import lombok.NoArgsConstructor;
9-
import lombok.Setter;
109

1110
/**
1211
* 시나리오 비교 결과를 저장하는 엔티티.
@@ -15,7 +14,6 @@
1514
@Entity
1615
@Table(name = "scene_compare")
1716
@Getter
18-
@Setter
1917
@NoArgsConstructor
2018
@AllArgsConstructor
2119
@Builder

back/src/main/java/com/back/domain/scenario/entity/SceneType.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,13 @@
66
import lombok.Builder;
77
import lombok.Getter;
88
import lombok.NoArgsConstructor;
9-
import lombok.Setter;
109

1110
/**
1211
* 시나리오의 특정 유형(경제, 행복 등)에 대한 상세 분석 정보를 저장하는 엔티티.
1312
*/
1413
@Entity
1514
@Table(name = "scene_type")
1615
@Getter
17-
@Setter
1816
@NoArgsConstructor
1917
@AllArgsConstructor
2018
@Builder

0 commit comments

Comments
 (0)