Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
518c068
feat: AI 서버 내 API 변경에 따른 Request 수정 및 테스트
ownue Jul 29, 2026
2a9e051
feat: 분석 요청 생성 및 비동기 AI 분석 처리 구현 (#74)
ownue Jul 30, 2026
6e316d6
fix: PENDING 상태의 AI 분석 요청 재처리 및 중복 실행 방지 (#74)
ownue Jul 30, 2026
34bab96
fix: AI 분석 응답 점수 검증 강화 (#74)
ownue Jul 30, 2026
e85384c
fix: 중단된 AI 분석 작업 복구 처리 추가 (#74)
ownue Jul 30, 2026
057666f
feat: Gemini 기반 분석 리포트 생성 및 규칙 기반 폴백 구현 (#22, #74)
ownue Jul 30, 2026
7aba6bf
feat: Gemini 리포트 생성 LLM 호출 로그 저장 (#74)
ownue Jul 30, 2026
846b6dd
fix: 분석 결과 사전 검증 및 재처리 안정성 개선 (#74)
ownue Jul 30, 2026
2aaede2
docs: 분석 API Swagger 문서화 추가 (#74)
ownue Jul 30, 2026
99f8ee5
fix: 분석 JSON 필드의 PostgreSQL 타입 매핑 수정 (#74)
ownue Jul 30, 2026
082e895
fix: Gemini 리포트 생성 read timeout 조정 (#74)
ownue Jul 30, 2026
4e9c755
feat: 분석 완료 통계 집계 연동 및 충돌 해결 후 머지 (#74)
ownue Jul 30, 2026
d18a22c
merge: 최신 develop 반영 및 통계 충돌 해결 (#74)
ownue Jul 30, 2026
7025b62
fix: 테스트 과정에서 깨진 문자들 리터럴 치환 (#74)
ownue Jul 30, 2026
cbc667e
chore: 패키지 분리 (#74)
ownue Jul 31, 2026
a8a8c67
fix: Optional.of()에 null이 들어올 수 있는 경로 방어
ownue Jul 31, 2026
477476b
fix: 분석 복구 작업 중복 완료 방지를 위한 펜싱 적용
ownue Jul 31, 2026
98a78ce
fix: Gemini 리포트 Markdown 형식 검증 추가 (#74)
ownue Jul 31, 2026
7f68ffd
fix: 분석 처리 안정성과 소유권 계약 강화 (#74)
ownue Jul 31, 2026
0ede655
feat: 분석 요약과 Gemini 리포트 품질 개선 (#74)
ownue Jul 31, 2026
b8a0bef
feat(analysis): 분석 결과 조회 응답을 명세에 동기화 (#74)
ownue Jul 31, 2026
9d1a910
fix: 분석 처리 안정성과 오류 응답을 개선 (#74)
ownue Jul 31, 2026
5d89bd3
Merge branch 'develop' of https://github.com/Musereview/BE into feat/…
ownue Jul 31, 2026
70eb3d9
fix: 유효하지 않은 분석 요청의 무한 재시도 방지 추가 (#74)
ownue Jul 31, 2026
98fd8f4
refactor: 분석 처리 시각 Clock 기반으로 통일 (#74)
ownue Jul 31, 2026
0b8d4a3
fix: 분석 결과 문자열 변환 안정성 개선 (#74)
ownue Jul 31, 2026
cb30309
refactor: 분석 처리 안정성 및 시간 관리 개선 (#74)
ownue Jul 31, 2026
82025cf
fix: 완료된 연주의 중복 분석 요청 차단 (#74)
ownue Jul 31, 2026
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
16 changes: 9 additions & 7 deletions MR_config/local/application.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ oauth:
# AI 도입 및 AWS S3 공통 구조
external:
ai:
base-url: ${AI_BASE_URL:}
api-key: ${AI_API_KEY:}
model: ${AI_MODEL:}
base-url: ${AI_BASE_URL:https://generativelanguage.googleapis.com}
api-key: ${GEMINI_API_KEY:${AI_API_KEY:}}
model: ${AI_MODEL:gemini-3-flash-preview}
connect-timeout: ${AI_CONNECT_TIMEOUT:5s}
read-timeout: ${AI_READ_TIMEOUT:120s}

aws:
s3:
Expand All @@ -63,11 +65,11 @@ aws:
# 내부 AI 분석 서버 설정
ai:
internal:
base-url: ${AI_INTERNAL_BASE_URL}
connect-timeout: ${AI_INTERNAL_CONNECT_TIMEOUT}
read-timeout: ${AI_INTERNAL_READ_TIMEOUT}
base-url: ${AI_INTERNAL_BASE_URL:https://ai.musereview.site}
connect-timeout: ${AI_INTERNAL_CONNECT_TIMEOUT:5s}
read-timeout: ${AI_INTERNAL_READ_TIMEOUT:60s}
endpoints:
analyze: ${AI_INTERNAL_ANALYZE_ENDPOINT}
analyze: ${AI_INTERNAL_ANALYZE_ENDPOINT:/analyze}

# 프로필 관련 공통 설정
app:
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/mr/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableRetry
@EnableScheduling
@EnableJpaAuditing
@SpringBootApplication
public class Application {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,48 @@
package com.mr.domain.analysis.controller;

import com.mr.domain.analysis.dto.req.AnalysisCreateRequestDTO;
import com.mr.domain.analysis.dto.res.AnalysisCreateResponseDTO;
import com.mr.domain.analysis.dto.res.AnalysisResultResponseDTO;
import com.mr.domain.analysis.dto.res.AnalysisStatusResponseDTO;
import com.mr.domain.analysis.service.AnalysisService;
import com.mr.global.apipayload.ApiResponse;
import com.mr.global.security.SecurityUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/analyses")
@Tag(name = "분석", description = "연주 분석 요청 및 결과 조회 API")
public class AnalysisController {

private final AnalysisService analysisService;

@PostMapping
@Operation(
summary = "분석 요청 생성 API",
description = "연주 데이터를 기반으로 AI 분석을 비동기로 요청합니다."
)
public ApiResponse<AnalysisCreateResponseDTO> createAnalysis(
@Valid @RequestBody AnalysisCreateRequestDTO request
) {
Long userId = SecurityUtil.getCurrentUserId();
return ApiResponse.onSuccess(analysisService.createAnalysis(userId, request));
}

@GetMapping("/{analysisId}/status")
@Operation(
summary = "분석 상태 조회 API",
description = "분석 요청의 현재 처리 상태를 조회합니다."
)
public ApiResponse<AnalysisStatusResponseDTO> getAnalysisStatus(
@PathVariable Long analysisId
) {
Expand All @@ -30,6 +54,10 @@ public ApiResponse<AnalysisStatusResponseDTO> getAnalysisStatus(
}

@GetMapping("/{analysisId}")
@Operation(
summary = "분석 결과 조회 API",
description = "완료된 분석 결과와 생성된 리포트를 조회합니다."
)
public ApiResponse<AnalysisResultResponseDTO> getAnalysisResult(
@PathVariable Long analysisId
) {
Expand All @@ -39,4 +67,4 @@ public ApiResponse<AnalysisResultResponseDTO> getAnalysisResult(
analysisService.getAnalysisResult(userId, analysisId)
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.mr.domain.analysis.dto.req;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

public record AnalysisCreateRequestDTO(
@NotNull Long playingId,
@NotNull @Positive Integer startBar,
@NotNull @Positive Integer endBar
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.mr.domain.analysis.dto.res;

import com.mr.domain.analysis.entity.Analysis;
import com.mr.domain.analysis.entity.enums.AnalysisStatus;
import java.time.LocalDateTime;

public record AnalysisCreateResponseDTO(
Long analysisId,
Long playingId,
AnalysisStatus status,
Integer startBar,
Integer endBar,
LocalDateTime createdAt
) {
public static AnalysisCreateResponseDTO from(Analysis analysis) {
return new AnalysisCreateResponseDTO(analysis.getId(), analysis.getPlaying().getId(), analysis.getStatus(),
analysis.getStartBar(), analysis.getEndBar(), analysis.getCreatedAt());
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.mr.domain.analysis.dto.res;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.mr.domain.analysis.entity.enums.AnalysisStatus;
import com.mr.domain.backingTrack.entity.BackingTrack;
import com.mr.domain.playing.entity.Playing;
import com.mr.domain.analysis.entity.Analysis;
import com.mr.domain.analysis.entity.AnalysisReport;
import com.mr.domain.analysis.entity.enums.AnalysisGrade;
Expand All @@ -9,18 +13,25 @@
import com.mr.domain.analysis.entity.enums.ReportGenerationType;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Locale;

public record AnalysisResultResponseDTO(
Long analysisId,
Long playingId,
String title,
String genre,
String key,
Integer bpm,
LocalDateTime playedAt,
AnalysisStatus status,
Integer startBar,
Integer endBar,
Integer totalScore,
AnalysisGrade grade,
String summary,
DomainScores domainScores,
Report report,
JsonNode rawResult,
@JsonProperty("result") JsonNode rawResult,
LocalDateTime createdAt,
LocalDateTime completedAt
) {
Expand All @@ -30,9 +41,17 @@ public static AnalysisResultResponseDTO from(
AnalysisReport analysisReport,
JsonNode rawResult
) {
Playing playing = analysis.getPlaying();
BackingTrack backingTrack = playing.getBackingTrack();
return new AnalysisResultResponseDTO(
analysis.getId(),
analysis.getPlaying().getId(),
playing.getId(),
backingTrack.getTitle(),
backingTrack.getGenre(),
formatKey(backingTrack),
playing.getBpm(),
playing.getEndedAt(),
analysis.getStatus(),
analysis.getStartBar(),
analysis.getEndBar(),
analysis.getTotalScore(),
Expand All @@ -46,11 +65,19 @@ public static AnalysisResultResponseDTO from(
);
}

private static String formatKey(BackingTrack backingTrack) {
String scale = backingTrack.getScaleType().name().toLowerCase(Locale.ROOT);
return backingTrack.getKeySignature()
+ " "
+ Character.toUpperCase(scale.charAt(0))
+ scale.substring(1);
Comment thread
ownue marked this conversation as resolved.
}

public record DomainScores(
BigDecimal scaleScore,
BigDecimal tensionScore,
BigDecimal progressionScore,
BigDecimal voiceLeadingScore
@JsonProperty("scale") BigDecimal scaleScore,
@JsonProperty("tension") BigDecimal tensionScore,
@JsonProperty("progression") BigDecimal progressionScore,
@JsonProperty("voiceLeading") BigDecimal voiceLeadingScore
) {

private static DomainScores from(Analysis analysis) {
Expand Down Expand Up @@ -93,4 +120,4 @@ private static Report fromNullable(AnalysisReport analysisReport) {
);
}
}
}
}
41 changes: 36 additions & 5 deletions src/main/java/com/mr/domain/analysis/entity/Analysis.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

@Getter
@Entity
Expand Down Expand Up @@ -65,6 +68,7 @@ public class Analysis extends BaseCreatedEntity {
@Column(name = "summary", columnDefinition = "text")
private String summary;

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "analysis_request_json", nullable = false, columnDefinition = "json")
private String analysisRequestJson;

Expand All @@ -80,12 +84,16 @@ public class Analysis extends BaseCreatedEntity {
@Column(name = "voice_leading_score", precision = 5, scale = 2)
private BigDecimal voiceLeadingScore;

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "raw_result_json", columnDefinition = "json")
private String rawResultJson;

@Column(name = "failed_reason", columnDefinition = "text")
private String failedReason;

@Column(name = "processing_started_at")
private LocalDateTime processingStartedAt;

@Column(name = "completed_at")
private LocalDateTime completedAt;

Expand Down Expand Up @@ -164,16 +172,39 @@ private static void validateBarRange(Integer startBar, Integer endBar) {
}
}

public void startProcessing() {
public LocalDateTime startProcessing(LocalDateTime now) {
if (this.status != AnalysisStatus.PENDING) {
throw new IllegalStateException("PENDING 상태의 분석만 PROCESSING으로 변경할 수 있습니다.");
}
this.status = AnalysisStatus.PROCESSING;
this.processingStartedAt = nextProcessingStartedAt(now);
return this.processingStartedAt;
}

public LocalDateTime restartProcessing(LocalDateTime now) {
if (this.status != AnalysisStatus.PROCESSING) {
throw new IllegalStateException("PROCESSING 상태의 분석만 다시 시작할 수 있습니다.");
}
this.processingStartedAt = nextProcessingStartedAt(now);
return this.processingStartedAt;
}

public boolean isCurrentProcessing(LocalDateTime expectedProcessingStartedAt) {
return this.status == AnalysisStatus.PROCESSING
&& Objects.equals(this.processingStartedAt, expectedProcessingStartedAt);
}

private LocalDateTime nextProcessingStartedAt(LocalDateTime requestedAt) {
LocalDateTime next = Objects.requireNonNull(requestedAt).truncatedTo(ChronoUnit.MILLIS);
if (this.processingStartedAt != null && !next.isAfter(this.processingStartedAt)) {
return this.processingStartedAt.plus(1, ChronoUnit.MILLIS);
}
return next;
}

public void complete(Integer totalScore, AnalysisGrade grade, String summary, BigDecimal scaleScore,
BigDecimal tensionScore, BigDecimal progressionScore, BigDecimal voiceLeadingScore,
String rawResultJson) {
String rawResultJson, LocalDateTime completedAt) {
if (this.status != AnalysisStatus.PROCESSING) {
throw new IllegalStateException("PROCESSING 상태의 분석만 완료 처리할 수 있습니다.");
}
Expand All @@ -186,15 +217,15 @@ public void complete(Integer totalScore, AnalysisGrade grade, String summary, Bi
this.progressionScore = progressionScore;
this.voiceLeadingScore = voiceLeadingScore;
this.rawResultJson = rawResultJson;
this.completedAt = LocalDateTime.now();
this.completedAt = Objects.requireNonNull(completedAt);
}

public void fail(String failedReason) {
public void fail(String failedReason, LocalDateTime completedAt) {
if (this.status == AnalysisStatus.COMPLETED || this.status == AnalysisStatus.FAILED) {
throw new IllegalStateException("이미 완료된 분석은 실패 처리할 수 없습니다.");
}
this.status = AnalysisStatus.FAILED;
this.failedReason = failedReason;
this.completedAt = LocalDateTime.now();
this.completedAt = Objects.requireNonNull(completedAt);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.mr.domain.analysis.event;

public record AnalysisRequestedEvent(Long analysisId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.mr.domain.analysis.event.listener;

import com.mr.domain.analysis.event.AnalysisRequestedEvent;
import com.mr.domain.analysis.service.AnalysisProcessingService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@Slf4j
public class AnalysisRequestedEventListener {

private final AnalysisProcessingService analysisProcessingService;
private final TaskExecutor taskExecutor;

public AnalysisRequestedEventListener(
AnalysisProcessingService analysisProcessingService,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor
) {
this.analysisProcessingService = analysisProcessingService;
this.taskExecutor = taskExecutor;
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(AnalysisRequestedEvent event) {
try {
taskExecutor.execute(() -> analysisProcessingService.process(event.analysisId()));
} catch (RuntimeException exception) {
log.warn("AI analysis submission failed; it will be retried by recovery. analysisId={}",
event.analysisId(), exception);
}
}
}
Loading
Loading