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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ uploads/

# 테스트 이미지 경로 (LocalStorageServiceTest)
test-uploads/
./test-uploads/
./test-uploads/

### Environment Variables ###
.env
4 changes: 0 additions & 4 deletions back/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,6 @@ out/
### Environment Variables ###
.env

# AI 생성 이미지 저장 경로
uploads/
./uploads/

# 테스트 이미지 경로 (LocalStorageServiceTest)
test-uploads/
./test-uploads/
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public interface ScenarioRepository extends JpaRepository<Scenario, Long> {
@Query("SELECT s FROM Scenario s LEFT JOIN FETCH s.baseLine bl LEFT JOIN FETCH bl.scenarios WHERE s.id = :id AND s.user.id = :userId")
Optional<Scenario> findByIdAndUserId(@Param("id") Long id, @Param("userId") Long userId);

// 시나리오 상태 폴링용 조회 (권한 검증 포함, 최소 데이터만 로딩)
@Query("SELECT s FROM Scenario s WHERE s.id = :id AND s.user.id = :userId")
Optional<Scenario> findByIdAndUserIdForStatusCheck(@Param("id") Long id, @Param("userId") Long userId);

// 베이스 시나리오 조회
Optional<Scenario> findByBaseLineIdAndDecisionLineIsNull(Long baseLineId);

Expand All @@ -33,6 +37,22 @@ public interface ScenarioRepository extends JpaRepository<Scenario, Long> {
@Query("SELECT s FROM Scenario s WHERE s.decisionLine.id = :decisionLineId")
Optional<Scenario> findByDecisionLineId(@Param("decisionLineId") Long decisionLineId);

// AI 생성용 시나리오 조회 - 1단계: DecisionLine + DecisionNodes
@Query("SELECT DISTINCT s FROM Scenario s " +
"LEFT JOIN FETCH s.decisionLine dl " +
"LEFT JOIN FETCH dl.decisionNodes dn " +
"WHERE s.id = :id")
Optional<Scenario> findByIdWithDecisionNodes(@Param("id") Long id);

// AI 생성용 시나리오 조회 - 2단계: BaseLine + BaseNodes + User
@Query("SELECT DISTINCT s FROM Scenario s " +
"LEFT JOIN FETCH s.decisionLine dl " +
"LEFT JOIN FETCH dl.baseLine bl " +
"LEFT JOIN FETCH bl.baseNodes bn " +
"LEFT JOIN FETCH bl.user " +
"WHERE s.id = :id")
Optional<Scenario> findByIdWithDecisionLineAndBaseLine(@Param("id") Long id);

// DecisionLine 기반 시나리오 조회 (권한 검증 포함, baseLine.scenarios fetch)
@Query("SELECT s FROM Scenario s LEFT JOIN FETCH s.baseLine bl LEFT JOIN FETCH bl.scenarios WHERE s.decisionLine.id = :decisionLineId AND s.user.id = :userId")
Optional<Scenario> findByDecisionLineIdAndUserId(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ private ScenarioValidationResult validateScenarioCreation(
ScenarioCreateRequest request,
@Nullable DecisionNodeNextRequest lastDecision) {

// DecisionLine 존재 여부 확인
DecisionLine decisionLine = decisionLineRepository.findById(request.decisionLineId())
// DecisionLine 존재 여부 확인 (User EAGER 로딩)
DecisionLine decisionLine = decisionLineRepository.findWithUserById(request.decisionLineId())
.orElseThrow(() -> new ApiException(ErrorCode.DECISION_LINE_NOT_FOUND));

// 권한 검증
Expand Down Expand Up @@ -312,17 +312,20 @@ public void processScenarioGenerationAsync(Long scenarioId) {
// 1. 상태를 PROCESSING으로 업데이트 (별도 트랜잭션)
scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.PROCESSING, null);

// 2. AI 시나리오 생성 (트랜잭션 외부에서 실행)
AiScenarioGenerationResult result = executeAiGeneration(scenarioId);
// 2. AI 생성에 필요한 모든 데이터를 트랜잭션 내에서 미리 로드
Scenario scenarioWithData = scenarioTransactionService.prepareScenarioData(scenarioId);

// 3. 결과 저장 및 완료 상태 업데이트 (별도 트랜잭션)
// 3. AI 시나리오 생성 (트랜잭션 외부에서 실행)
AiScenarioGenerationResult result = executeAiGeneration(scenarioWithData);

// 4. 결과 저장 및 완료 상태 업데이트 (별도 트랜잭션)
scenarioTransactionService.saveAiResult(scenarioId, result);
scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.COMPLETED, null);

log.info("Scenario generation completed successfully for ID: {}", scenarioId);

} catch (Exception e) {
// 4. 실패 상태 업데이트 (별도 트랜잭션)
// 5. 실패 상태 업데이트 (별도 트랜잭션)
scenarioTransactionService.updateScenarioStatus(scenarioId, ScenarioStatus.FAILED,
"시나리오 생성 실패: " + e.getMessage());
log.error("Scenario generation failed for ID: {}, error: {}",
Expand All @@ -331,11 +334,8 @@ public void processScenarioGenerationAsync(Long scenarioId) {
}

// AI 호출 전용 메서드 (트랜잭션 없음)
private AiScenarioGenerationResult executeAiGeneration(Long scenarioId) {
Scenario scenario = scenarioRepository.findById(scenarioId)
.orElseThrow(() -> new ApiException(ErrorCode.SCENARIO_NOT_FOUND));

// AI 호출 로직 (트랜잭션 외부에서 실행)
private AiScenarioGenerationResult executeAiGeneration(Scenario scenario) {
// AI 호출 로직 (미리 로드된 데이터 사용)
DecisionLine decisionLine = scenario.getDecisionLine();
BaseLine baseLine = decisionLine.getBaseLine();

Expand All @@ -347,7 +347,7 @@ private AiScenarioGenerationResult executeAiGeneration(Long scenarioId) {
.generateDecisionScenario(decisionLine, baseScenario)
.orTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.exceptionally(ex -> {
log.error("Decision scenario generation timeout or error for scenario ID: {}", scenarioId, ex);
log.error("Decision scenario generation timeout or error for scenario ID: {}", scenario.getId(), ex);
throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, "시나리오 생성 시간 초과 (60초)");
})
.join();
Expand All @@ -365,12 +365,12 @@ private Scenario ensureBaseScenarioExists(BaseLine baseLine) {
private Scenario createBaseScenario(BaseLine baseLine) {
log.info("Creating base scenario for BaseLine ID: {}", baseLine.getId());

// 1. AI 호출 with 타임아웃 (60초)
// 1. AI 호출 with 타임아웃 (180초 - 테스트용)
BaseScenarioResult aiResult = aiService.generateBaseScenario(baseLine)
.orTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.orTimeout(180, java.util.concurrent.TimeUnit.SECONDS)
.exceptionally(ex -> {
log.error("Base scenario generation timeout or error for BaseLine ID: {}", baseLine.getId(), ex);
throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, "베이스 시나리오 생성 시간 초과 (60초)");
throw new ApiException(ErrorCode.AI_REQUEST_TIMEOUT, "베이스 시나리오 생성 시간 초과 (180초)");
})
.join();

Expand All @@ -394,7 +394,7 @@ private Scenario createBaseScenario(BaseLine baseLine) {
@Transactional(readOnly = true)
public ScenarioStatusResponse getScenarioStatus(Long scenarioId, Long userId) {
// 권한 검증 및 조회
Scenario scenario = scenarioRepository.findByIdAndUserId(scenarioId, userId)
Scenario scenario = scenarioRepository.findByIdAndUserIdForStatusCheck(scenarioId, userId)
.orElseThrow(() -> new ApiException(ErrorCode.SCENARIO_NOT_FOUND));

// DTO 변환 및 반환
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.back.domain.scenario.repository.SceneTypeRepository;
import com.back.global.ai.dto.result.BaseScenarioResult;
import com.back.global.ai.dto.result.DecisionScenarioResult;
import com.back.global.ai.exception.AiParsingException;
import com.back.global.ai.service.AiService;
import com.back.global.exception.ApiException;
import com.back.global.exception.ErrorCode;
Expand Down Expand Up @@ -44,6 +45,7 @@ public class ScenarioTransactionService {
private final BaseLineRepository baseLineRepository;
private final AiService aiService;
private final ObjectMapper objectMapper;
private final com.back.global.ai.config.ImageAiConfig imageAiConfig;

// 상태 업데이트 전용 트랜잭션 메서드
@Transactional(propagation = Propagation.REQUIRES_NEW)
Expand Down Expand Up @@ -138,7 +140,7 @@ private void handleImageGeneration(Scenario scenario, String imagePrompt) {
try {
if (imagePrompt != null && !imagePrompt.trim().isEmpty()) {
String imageUrl = aiService.generateImage(imagePrompt)
.orTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
.orTimeout(imageAiConfig.getTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS)
.exceptionally(ex -> {
log.warn("Image generation timeout or error for scenario {}: {}",
scenario.getId(), ex.getMessage());
Expand All @@ -164,24 +166,30 @@ private void handleImageGeneration(Scenario scenario, String imagePrompt) {
}

private int calculateTotalScore(Map<Type, Integer> indicatorScores) {
if (indicatorScores == null || indicatorScores.isEmpty()) {
log.warn("indicatorScores is null or empty, returning 0");
return 0;
}
return indicatorScores.values().stream()
.mapToInt(Integer::intValue)
.sum();
}

private void createBaseSceneTypes(Scenario scenario, BaseScenarioResult aiResult) {
List<SceneType> sceneTypes = aiResult.indicatorScores()
.entrySet().stream()
.map(entry -> {
Type type = entry.getKey();
int point = entry.getValue();
String analysis = aiResult.indicatorAnalysis().get(type);
if (aiResult.indicators() == null || aiResult.indicators().isEmpty()) {
log.warn("No indicator scores available for scenario {}", scenario.getId());
return;
}

List<SceneType> sceneTypes = aiResult.indicators().stream()
.map(indicator -> {
Type type = Type.valueOf(indicator.type());

return com.back.domain.scenario.entity.SceneType.builder()
.scenario(scenario)
.type(type)
.point(point) // AI가 분석한 실제 점수
.analysis(analysis != null ? analysis : "현재 " + type.name() + " 상황 분석")
.point(indicator.point()) // AI가 분석한 실제 점수
.analysis(indicator.analysis() != null ? indicator.analysis() : "현재 " + type.name() + " 상황 분석")
.build();
})
.toList();
Expand All @@ -190,18 +198,19 @@ private void createBaseSceneTypes(Scenario scenario, BaseScenarioResult aiResult
}

private void createDecisionSceneTypes(Scenario scenario, DecisionScenarioResult aiResult) {
List<com.back.domain.scenario.entity.SceneType> sceneTypes = aiResult.indicatorScores()
.entrySet().stream()
.map(entry -> {
Type type = entry.getKey();
int point = entry.getValue();
String analysis = aiResult.indicatorAnalysis().get(type);
if (aiResult.indicators() == null || aiResult.indicators().isEmpty()) {
throw new AiParsingException("Indicator scores are missing in AI response for scenario " + scenario.getId());
}

List<com.back.domain.scenario.entity.SceneType> sceneTypes = aiResult.indicators().stream()
.map(indicator -> {
Type type = Type.valueOf(indicator.type());

return com.back.domain.scenario.entity.SceneType.builder()
.scenario(scenario)
.type(type)
.point(point)
.analysis(analysis != null ? analysis : "분석 정보를 가져올 수 없습니다.")
.point(indicator.point())
.analysis(indicator.analysis() != null ? indicator.analysis() : "분석 정보를 가져올 수 없습니다.")
.build();
})
.toList();
Expand All @@ -210,10 +219,12 @@ private void createDecisionSceneTypes(Scenario scenario, DecisionScenarioResult
}

private void createSceneCompare(Scenario scenario, DecisionScenarioResult aiResult) {
List<SceneCompare> compares = aiResult.comparisonResults()
.entrySet().stream()
.map(entry -> {
String resultTypeStr = entry.getKey().toUpperCase();
if (aiResult.comparisons() == null || aiResult.comparisons().isEmpty()) {
throw new AiParsingException("Comparison results are missing in AI response for scenario " + scenario.getId());
}
List<SceneCompare> compares = aiResult.comparisons().stream()
.map(comparison -> {
String resultTypeStr = comparison.type().toUpperCase();
com.back.domain.scenario.entity.SceneCompareResultType resultType;
try {
resultType = com.back.domain.scenario.entity.SceneCompareResultType.valueOf(resultTypeStr);
Expand All @@ -225,7 +236,7 @@ private void createSceneCompare(Scenario scenario, DecisionScenarioResult aiResu
return SceneCompare.builder()
.scenario(scenario)
.resultType(resultType)
.compareResult(entry.getValue())
.compareResult(comparison.analysis())
.build();
})
.toList();
Expand All @@ -240,4 +251,21 @@ private void updateBaseLineTitle(BaseLine baseLine, String baselineTitle) {
log.info("BaseLine title updated for ID: {}", baseLine.getId());
}
}

@Transactional(readOnly = true)
public Scenario prepareScenarioData(Long scenarioId) {
// MultipleBagFetchException을 피하기 위해 별도 쿼리로 각 컬렉션을 초기화
// 1. DecisionNodes 초기화
Scenario scenario = scenarioRepository.findById(scenarioId)
.orElseThrow(() -> new ApiException(ErrorCode.SCENARIO_NOT_FOUND));
scenario.getDecisionLine().getDecisionNodes().size(); // 프록시 초기화

// 2. BaseNodes 초기화
scenario.getBaseLine().getBaseNodes().size(); // 프록시 초기화

// 3. User 엔티티 초기화 (새로운 오류 방지)
scenario.getUser().getMbti(); // User 프록시 초기화

return scenario;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,31 +118,42 @@ private Mono<String> extractImageData(String response) {
try {
JsonNode rootNode = objectMapper.readTree(response);

// Stability AI 공식 응답 구조: { "artifacts": [{ "base64": "..." }] }
if (rootNode.has("artifacts") && rootNode.get("artifacts").isArray()) {
// 1. 단순 응답 구조 체크: {"image": "..."}
if (rootNode.has("image")) {
String base64Data = rootNode.get("image").asText();
if (base64Data != null && !base64Data.isEmpty()) {
log.info("Image generated successfully (simple structure). Base64 length: {}", base64Data.length());
return Mono.just(base64Data);
}
}

// 2. 공식 응답 구조 체크: { "artifacts": [{...}] }
if (rootNode.has("artifacts") && rootNode.get("artifacts").isArray() && !rootNode.get("artifacts").isEmpty()) {
JsonNode firstArtifact = rootNode.get("artifacts").get(0);

// finishReason 검증
if (firstArtifact.has("finishReason")) {
String finishReason = firstArtifact.get("finishReason").asText();
if (!"SUCCESS".equals(finishReason)) {
log.error("Image generation failed with reason: {}", finishReason);
return Mono.error(new AiServiceException(
ErrorCode.AI_GENERATION_FAILED,
"Image generation failed: " + finishReason
));
if (firstArtifact != null) {
// finishReason 검증
if (firstArtifact.has("finishReason")) {
String finishReason = firstArtifact.get("finishReason").asText();
if (!"SUCCESS".equals(finishReason)) {
log.error("Image generation failed with reason: {}", finishReason);
return Mono.error(new AiServiceException(
ErrorCode.AI_GENERATION_FAILED,
"Image generation failed: " + finishReason
));
}
}
}

// Base64 데이터 추출
if (firstArtifact.has("base64")) {
String base64Data = firstArtifact.get("base64").asText();
log.info("Image generated successfully. Base64 length: {}", base64Data.length());
return Mono.just(base64Data);
// Base64 데이터 추출
if (firstArtifact.has("base64")) {
String base64Data = firstArtifact.get("base64").asText();
log.info("Image generated successfully (artifacts structure). Base64 length: {}", base64Data.length());
return Mono.just(base64Data);
}
}
}

// 응답 구조가 예상과 다를 경우
// 두 구조 모두 해당하지 않을 경우
log.error("Unexpected Stable Diffusion API response structure: {}", response);
return Mono.error(new AiServiceException(
ErrorCode.AI_GENERATION_FAILED,
Expand Down
Loading