diff --git a/.gitignore b/.gitignore index 7064c441..bc8cacfd 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,10 @@ awscliv2.zip # Spring Boot local config src/main/resources/application-local.yml +# copyPrivateConfig 태스크가 MR_config/local에서 복사해 오는 빌드 산출물 (실제 비밀키 포함 가능) +src/main/resources/application.yml +src/main/resources/application.example.yml + # docs docs/* .claude/* diff --git a/MR_config/local/application.example.yml b/MR_config/local/application.example.yml index 71dfa30d..7bffe47d 100644 --- a/MR_config/local/application.example.yml +++ b/MR_config/local/application.example.yml @@ -1,52 +1,65 @@ -# ================================================================= # -# 🔒 [NOTICE] 본 파일은 로컬 환경 세팅용 '공통 예시 파일'입니다. -# 각자 로컬 PC 사양에 맞게 정보를 입력한 후, 파일 이름을 -# 'application.yml'로 변경하여 동일한 경로에 위치시켜 주세요! -# ================================================================= # - spring: - # 1. 로컬 데이터베이스 커넥션 설정 (PostgreSQL 18) + config: + import: + - optional:file:.env[.properties] #.env 파일 자동 로드 + + application: + name: musereview-backend + + # 공통 데이터베이스 접속 규칙 (실제 정보는 각자 .env나 application-prod에서 오버라이딩) datasource: driver-class-name: org.postgresql.Driver - url: jdbc:postgresql://localhost:5432/mr_db # [체크] 로컬에 mr_db 빈 데이터베이스를 먼저 생성 - username: postgres # [입력] PostgreSQL 계정명 - password: "" # [입력] PostgreSQL 비밀번호 (빈값 입력 가능) + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/mr_db} + username: ${SPRING_DATASOURCE_USERNAME:postgres} + password: ${SPRING_DATASOURCE_PASSWORD:} - # 2. JPA 및 하이버네이트 구동 설정 + # 공통 JPA 및 하이버네이트 구동 설정 jpa: hibernate: - ddl-auto: update # 엔티티 매핑 정보 변경 시 DB 테이블 자동 반영 - show-sql: true # 콘솔에 실행 SQL 포맷 출력 + ddl-auto: ${SPRING_JPA_DDL_AUTO:update} # 배포 환경(prod)에선 validate로 덮어씌워짐 + show-sql: true properties: hibernate: format_sql: true dialect: org.hibernate.dialect.PostgreSQLDialect -# 3. Spring Security 및 JWT 인증 설정 + # 파일 업로드 용량 제한 + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB + +# Spring Security 및 JWT 인증 설정 jwt: - # [입력] Base64로 인코딩된 256비트(32바이트) 이상의 Secret Key를 채워주세요. - # (주의: Decoders.BASE64.decode를 사용하므로 Plain Text가 아닌 Base64 인코딩 문자열이어야 합니다.) - # 기입 예시: "c29tZS1zZWNyZXQta2V5LW11c3QtYmUtYXQtbGVhc3QtMzItYmF5dGVzLWxvbmc=" - secret: "" + secret: ${JWT_SECRET_KEY} access-token-validity-in-seconds: 1800 # Access Token 만료 시간 (30분) refresh-token-validity-in-seconds: 604800 # Refresh Token 만료 시간 (7일) -# 4. 외부 소셜 로그인 API 연동 정보 (OAuth용) +# 소셜 로그인 API 연동 oauth: kakao: - client-id: "" # [입력] 카카오 디벨로퍼스 REST API 키 - client-secret: "" # [입력] 카카오 보안 Client Secret 키 - redirect-uri: http://localhost:8080/login/oauth2/code/kakao + client-id: ${KAKAO_CLIENT_ID:} + client-secret: ${KAKAO_CLIENT_SECRET:} + redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:8080/login/oauth2/code/kakao} + google: - client-id: "" # [입력] 구글 클라우드 콘솔 OAuth 클라이언트 ID - client-secret: "" # [입력] 구글 클라우드 콘솔 보안 비밀번호 - redirect-uri: http://localhost:8080/login/oauth2/code/google - -# 5. AI 서버 연동 설정 -ai: - internal: - base-url: http://localhost:8000 # [입력] 로컬에서 띄운 AI 서버 주소 - connect-timeout: 3s - read-timeout: 30s - endpoints: - analyze: /analyze \ No newline at end of file + client-id: ${GOOGLE_CLIENT_ID:} + client-secret: ${GOOGLE_CLIENT_SECRET:} + redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:8080/login/oauth2/code/google} + +# AI 도입 및 AWS S3 공통 구조 +external: + ai: + base-url: ${AI_BASE_URL:} + api-key: ${AI_API_KEY:} + model: ${AI_MODEL:} + +aws: + s3: + bucket: ${AWS_S3_BUCKET:} + region: ${AWS_REGION:ap-northeast-2} + +# 프로필 관련 공통 설정 +app: + profile: + default-image-url: ${DEFAULT_PROFILE_IMAGE_URL:} \ No newline at end of file diff --git a/docker-compose.local.yml b/docker-compose.local.yml index ab4232c9..efca5562 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:16 + image: postgres:18 container_name: musereview-postgres ports: - "127.0.0.1:5433:5432" @@ -9,7 +9,7 @@ services: POSTGRES_USER: musereview POSTGRES_PASSWORD: musereview1234 volumes: - - musereview-postgres-data:/var/lib/postgresql/data + - musereview-postgres-data:/var/lib/postgresql volumes: musereview-postgres-data: diff --git a/src/main/java/com/mr/domain/analysis/controller/AnalysisController.java b/src/main/java/com/mr/domain/analysis/controller/AnalysisController.java new file mode 100644 index 00000000..107da8f3 --- /dev/null +++ b/src/main/java/com/mr/domain/analysis/controller/AnalysisController.java @@ -0,0 +1,42 @@ +package com.mr.domain.analysis.controller; + +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 lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/analyses") +public class AnalysisController { + + private final AnalysisService analysisService; + + @GetMapping("/{analysisId}/status") + public ApiResponse getAnalysisStatus( + @PathVariable Long analysisId + ) { + Long userId = SecurityUtil.getCurrentUserId(); + + return ApiResponse.onSuccess( + analysisService.getAnalysisStatus(userId, analysisId) + ); + } + + @GetMapping("/{analysisId}") + public ApiResponse getAnalysisResult( + @PathVariable Long analysisId + ) { + Long userId = SecurityUtil.getCurrentUserId(); + + return ApiResponse.onSuccess( + analysisService.getAnalysisResult(userId, analysisId) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/analysis/dto/res/AnalysisResultResponseDTO.java b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisResultResponseDTO.java new file mode 100644 index 00000000..ed6b9f62 --- /dev/null +++ b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisResultResponseDTO.java @@ -0,0 +1,96 @@ +package com.mr.domain.analysis.dto.res; + +import com.fasterxml.jackson.databind.JsonNode; +import com.mr.domain.analysis.entity.Analysis; +import com.mr.domain.analysis.entity.AnalysisReport; +import com.mr.domain.analysis.entity.enums.AnalysisGrade; +import com.mr.domain.analysis.entity.enums.ContentFormat; +import com.mr.domain.analysis.entity.enums.LlmStatus; +import com.mr.domain.analysis.entity.enums.ReportGenerationType; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record AnalysisResultResponseDTO( + Long analysisId, + Long playingId, + Integer startBar, + Integer endBar, + Integer totalScore, + AnalysisGrade grade, + String summary, + DomainScores domainScores, + Report report, + JsonNode rawResult, + LocalDateTime createdAt, + LocalDateTime completedAt +) { + + public static AnalysisResultResponseDTO from( + Analysis analysis, + AnalysisReport analysisReport, + JsonNode rawResult + ) { + return new AnalysisResultResponseDTO( + analysis.getId(), + analysis.getPlayingId(), + analysis.getStartBar(), + analysis.getEndBar(), + analysis.getTotalScore(), + analysis.getGrade(), + analysis.getSummary(), + DomainScores.from(analysis), + Report.fromNullable(analysisReport), + rawResult, + analysis.getCreatedAt(), + analysis.getCompletedAt() + ); + } + + public record DomainScores( + BigDecimal scaleScore, + BigDecimal tensionScore, + BigDecimal progressionScore, + BigDecimal voiceLeadingScore + ) { + + private static DomainScores from(Analysis analysis) { + return new DomainScores( + analysis.getScaleScore(), + analysis.getTensionScore(), + analysis.getProgressionScore(), + analysis.getVoiceLeadingScore() + ); + } + } + + public record Report( + Long analysisReportId, + ReportGenerationType generationType, + LlmStatus llmStatus, + ContentFormat contentFormat, + String content, + String modelName, + String promptVersion, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + + private static Report fromNullable(AnalysisReport analysisReport) { + if (analysisReport == null) { + return null; + } + + return new Report( + analysisReport.getId(), + analysisReport.getGenerationType(), + analysisReport.getLlmStatus(), + analysisReport.getContentFormat(), + analysisReport.getContent(), + analysisReport.getModelName(), + analysisReport.getPromptVersion(), + analysisReport.getCreatedAt(), + analysisReport.getUpdatedAt() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/analysis/dto/res/AnalysisStatusResponseDTO.java b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisStatusResponseDTO.java new file mode 100644 index 00000000..b32b36be --- /dev/null +++ b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisStatusResponseDTO.java @@ -0,0 +1,30 @@ +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 AnalysisStatusResponseDTO( + Long analysisId, + AnalysisStatus status, + Integer progressRate, + String message, + LocalDateTime createdAt, + LocalDateTime completedAt +) { + + public static AnalysisStatusResponseDTO from( + Analysis analysis, + Integer progressRate, + String message + ) { + return new AnalysisStatusResponseDTO( + analysis.getId(), + analysis.getStatus(), + progressRate, + message, + analysis.getCreatedAt(), + analysis.getCompletedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/analysis/entity/AnalysisReport.java b/src/main/java/com/mr/domain/analysis/entity/AnalysisReport.java index b2034e3e..d659b2f4 100644 --- a/src/main/java/com/mr/domain/analysis/entity/AnalysisReport.java +++ b/src/main/java/com/mr/domain/analysis/entity/AnalysisReport.java @@ -20,8 +20,8 @@ public class AnalysisReport extends BaseTimeEntity { @Column(name = "analysis_report_id") private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "analysis_id", nullable = false) + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "analysis_id", nullable = false, unique = true) private Analysis analysis; @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/mr/domain/analysis/exception/AnalysisErrorStatus.java b/src/main/java/com/mr/domain/analysis/exception/AnalysisErrorStatus.java new file mode 100644 index 00000000..7852914c --- /dev/null +++ b/src/main/java/com/mr/domain/analysis/exception/AnalysisErrorStatus.java @@ -0,0 +1,25 @@ +package com.mr.domain.analysis.exception; + +import com.mr.global.apipayload.code.BaseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AnalysisErrorStatus implements BaseCode { + + ANALYSIS_ACCESS_DENIED(HttpStatus.FORBIDDEN, "ANALYSIS_403_01", "해당 분석 결과에 접근할 수 없습니다."), + + ANALYSIS_NOT_FOUND(HttpStatus.NOT_FOUND, "ANALYSIS_404_01", "분석 결과를 찾을 수 없습니다."), + + ANALYSIS_NOT_COMPLETED(HttpStatus.CONFLICT, "ANALYSIS_409_01", "아직 완료되지 않은 분석입니다."), + + INVALID_RAW_RESULT(HttpStatus.INTERNAL_SERVER_ERROR, "ANALYSIS_500_01", "저장된 분석 결과를 처리할 수 없습니다."), + + ; + + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/analysis/repository/AnalysisReportRepository.java b/src/main/java/com/mr/domain/analysis/repository/AnalysisReportRepository.java new file mode 100644 index 00000000..a8a42ecb --- /dev/null +++ b/src/main/java/com/mr/domain/analysis/repository/AnalysisReportRepository.java @@ -0,0 +1,14 @@ +package com.mr.domain.analysis.repository; + +import com.mr.domain.analysis.entity.AnalysisReport; +import com.mr.domain.analysis.entity.enums.LlmStatus; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AnalysisReportRepository extends JpaRepository { + + Optional findFirstByAnalysisIdAndLlmStatusOrderByCreatedAtDesc( + Long analysisId, + LlmStatus llmStatus + ); +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/analysis/repository/AnalysisRepository.java b/src/main/java/com/mr/domain/analysis/repository/AnalysisRepository.java new file mode 100644 index 00000000..c4a69232 --- /dev/null +++ b/src/main/java/com/mr/domain/analysis/repository/AnalysisRepository.java @@ -0,0 +1,7 @@ +package com.mr.domain.analysis.repository; + +import com.mr.domain.analysis.entity.Analysis; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AnalysisRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/analysis/service/AnalysisService.java b/src/main/java/com/mr/domain/analysis/service/AnalysisService.java new file mode 100644 index 00000000..6cc0cac9 --- /dev/null +++ b/src/main/java/com/mr/domain/analysis/service/AnalysisService.java @@ -0,0 +1,131 @@ +package com.mr.domain.analysis.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mr.domain.analysis.dto.res.AnalysisResultResponseDTO; +import com.mr.domain.analysis.dto.res.AnalysisStatusResponseDTO; +import com.mr.domain.analysis.entity.Analysis; +import com.mr.domain.analysis.entity.AnalysisReport; +import com.mr.domain.analysis.entity.enums.AnalysisStatus; +import com.mr.domain.analysis.entity.enums.LlmStatus; +import com.mr.domain.analysis.exception.AnalysisErrorStatus; +import com.mr.domain.analysis.repository.AnalysisReportRepository; +import com.mr.domain.analysis.repository.AnalysisRepository; +import com.mr.global.apipayload.exception.GeneralException; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalysisService { + + private final AnalysisRepository analysisRepository; + private final AnalysisReportRepository analysisReportRepository; + private final ObjectMapper objectMapper; + + public AnalysisStatusResponseDTO getAnalysisStatus( + Long userId, + Long analysisId + ) { + Analysis analysis = getAnalysis(analysisId); + + validateOwner(analysis, userId); + + return AnalysisStatusResponseDTO.from( + analysis, + getProgressRate(analysis.getStatus()), + getStatusMessage(analysis.getStatus()) + ); + } + + public AnalysisResultResponseDTO getAnalysisResult( + Long userId, + Long analysisId + ) { + Analysis analysis = getAnalysis(analysisId); + + validateOwner(analysis, userId); + validateCompleted(analysis); + + AnalysisReport analysisReport = analysisReportRepository + .findFirstByAnalysisIdAndLlmStatusOrderByCreatedAtDesc(analysisId, LlmStatus.SUCCESS) + .orElse(null); + + JsonNode rawResult = parseRawResult( + analysis.getId(), + analysis.getRawResultJson() + ); + + return AnalysisResultResponseDTO.from( + analysis, + analysisReport, + rawResult + ); + } + + private Analysis getAnalysis(Long analysisId) { + return analysisRepository.findById(analysisId) + .orElseThrow(() -> + new GeneralException(AnalysisErrorStatus.ANALYSIS_NOT_FOUND) + ); + } + + private void validateOwner(Analysis analysis, Long userId) { + if (!Objects.equals(analysis.getUserId(), userId)) { + throw new GeneralException(AnalysisErrorStatus.ANALYSIS_ACCESS_DENIED); + } + } + + private void validateCompleted(Analysis analysis) { + if (analysis.getStatus() != AnalysisStatus.COMPLETED) { + throw new GeneralException(AnalysisErrorStatus.ANALYSIS_NOT_COMPLETED); + } + } + + private Integer getProgressRate(AnalysisStatus status) { + return switch (status) { + case PENDING -> 0; + case PROCESSING -> null; + case COMPLETED -> 100; + case FAILED -> null; + }; + } + + private String getStatusMessage(AnalysisStatus status) { + return switch (status) { + case PENDING -> "분석 요청을 기다리고 있습니다."; + case PROCESSING -> "연습 결과를 분석 중입니다."; + case COMPLETED -> "분석이 완료되었습니다."; + case FAILED -> "분석에 실패했습니다."; + }; + } + + private JsonNode parseRawResult( + Long analysisId, + String rawResultJson + ) { + if (rawResultJson == null || rawResultJson.isBlank()) { + return null; + } + + try { + return objectMapper.readTree(rawResultJson); + } catch (JsonProcessingException exception) { + log.error( + "분석 결과 JSON 변환 실패. analysisId={}", + analysisId, + exception + ); + + throw new GeneralException( + AnalysisErrorStatus.INVALID_RAW_RESULT + ); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.example.yml b/src/main/resources/application.example.yml deleted file mode 100644 index 18d2b032..00000000 --- a/src/main/resources/application.example.yml +++ /dev/null @@ -1,51 +0,0 @@ -# ================================================================= # -# 🔒 [NOTICE] 본 파일은 로컬 환경 세팅용 '공통 예시 파일'입니다. -# 각자 로컬 PC 사양에 맞게 정보를 입력한 후, 파일 이름을 -# 'application.yml'로 변경하여 동일한 경로에 위치시켜 주세요! -# ================================================================= # - -spring: - # 1. 로컬 데이터베이스 커넥션 설정 (PostgreSQL 18) - datasource: - driver-class-name: org.postgresql.Driver - url: jdbc:postgresql://localhost:5432/mr_db # [체크] 로컬에 mr_db 빈 데이터베이스를 먼저 생성 - username: postgres # [입력] PostgreSQL 계정명 - password: "" # [입력] PostgreSQL 비밀번호 (빈값 입력 가능) - - # 2. JPA 및 하이버네이트 구동 설정 - jpa: - hibernate: - ddl-auto: update # 엔티티 매핑 정보 변경 시 DB 테이블 자동 반영 - show-sql: true # 콘솔에 실행 SQL 포맷 출력 - properties: - hibernate: - format_sql: true - dialect: org.hibernate.dialect.PostgreSQLDialect - -# 3. Spring Security 및 JWT 인증 설정 -jwt: - # [입력] HS256 알고리즘을 충족하는 256비트(32바이트) 이상의 임의의 비밀키를 채워주세요. - # 기입 예시: "your-local-custom-secret-key-must-be-very-long-and-secure-32bytes" - secret: "" - access-token-validity-in-seconds: 1800 # Access Token 만료 시간 (30분) - refresh-token-validity-in-seconds: 604800 # Refresh Token 만료 시간 (7일) - -# 4. 외부 소셜 로그인 API 연동 정보 (OAuth용) -oauth: - kakao: - client-id: "" # [입력] 카카오 디벨로퍼스 REST API 키 - client-secret: "" # [입력] 카카오 보안 Client Secret 키 - redirect-uri: http://localhost:8080/login/oauth2/code/kakao - google: - client-id: "" # [입력] 구글 클라우드 콘솔 OAuth 클라이언트 ID - client-secret: "" # [입력] 구글 클라우드 콘솔 보안 비밀번호 - redirect-uri: http://localhost:8080/login/oauth2/code/google - -# 5. AI 서버 연동 설정 -ai: - internal: - base-url: http://localhost:8000 # [입력] 로컬에서 띄운 AI 서버 주소 - connect-timeout: 3s - read-timeout: 30s - endpoints: - analyze: /analyze \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 1ffa7a7e..00000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,67 +0,0 @@ -spring: - config: - import: - - optional:file:.env[.properties] #.env 파일 자동 로드 - - application: - name: musereview-backend - - # 공통 데이터베이스 접속 규칙 (실제 정보는 각자 .env나 application-prod에서 오버라이딩) - datasource: - driver-class-name: org.postgresql.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/mr_db} - username: ${SPRING_DATASOURCE_USERNAME:postgres} - password: ${SPRING_DATASOURCE_PASSWORD:} - - # 공통 JPA 및 하이버네이트 구동 설정 - jpa: - hibernate: - ddl-auto: ${SPRING_JPA_DDL_AUTO:update} # 배포 환경(prod)에선 validate로 덮어씌워짐 - show-sql: true - properties: - hibernate: - format_sql: true - dialect: org.hibernate.dialect.PostgreSQLDialect - - # 파일 업로드 용량 제한 - servlet: - multipart: - max-file-size: 20MB - max-request-size: 20MB - -# Spring Security 및 JWT 인증 설정 -jwt: - secret: ${JWT_SECRET_KEY} - access-token-validity-in-seconds: 1800 # Access Token 만료 시간 (30분) - refresh-token-validity-in-seconds: 604800 # Refresh Token 만료 시간 (7일) - -# 소셜 로그인 API 연동 -oauth: - kakao: - client-id: ${KAKAO_CLIENT_ID:} - client-secret: ${KAKAO_CLIENT_SECRET:} - redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:8080/login/oauth2/code/kakao} - - google: - client-id: ${GOOGLE_CLIENT_ID:} - client-secret: ${GOOGLE_CLIENT_SECRET:} - redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:8080/login/oauth2/code/google} - -ai: - internal: - base-url: ${AI_INTERNAL_BASE_URL:http://localhost:8000} - connect-timeout: ${AI_INTERNAL_CONNECT_TIMEOUT:3s} - read-timeout: ${AI_INTERNAL_READ_TIMEOUT:30s} - endpoints: - analyze: ${AI_INTERNAL_ANALYZE_ENDPOINT:/analyze} - # TODO: 외부 LLM(Gemini) 연동 설정 - 리포트 생성용, 별도 이슈에서 ai.external.* 로 base-url/api-key/model 등 확정 예정 - -aws: - s3: - bucket: ${AWS_S3_BUCKET:} - region: ${AWS_REGION:ap-northeast-2} - -# 프로필 관련 공통 설정 -app: - profile: - default-image-url: ${DEFAULT_PROFILE_IMAGE_URL:} diff --git a/src/test/java/com/mr/domain/analysis/controller/AnalysisControllerTest.java b/src/test/java/com/mr/domain/analysis/controller/AnalysisControllerTest.java new file mode 100644 index 00000000..217770e5 --- /dev/null +++ b/src/test/java/com/mr/domain/analysis/controller/AnalysisControllerTest.java @@ -0,0 +1,168 @@ +package com.mr.domain.analysis.controller; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mr.domain.analysis.dto.res.AnalysisResultResponseDTO; +import com.mr.domain.analysis.dto.res.AnalysisStatusResponseDTO; +import com.mr.domain.analysis.entity.enums.AnalysisGrade; +import com.mr.domain.analysis.entity.enums.AnalysisStatus; +import com.mr.domain.analysis.entity.enums.ContentFormat; +import com.mr.domain.analysis.entity.enums.LlmStatus; +import com.mr.domain.analysis.entity.enums.ReportGenerationType; +import com.mr.domain.analysis.exception.AnalysisErrorStatus; +import com.mr.domain.analysis.service.AnalysisService; +import com.mr.domain.user.entity.enums.UserRole; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.apipayload.handler.GlobalExceptionHandler; +import com.mr.global.security.principal.CustomUserDetails; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith(MockitoExtension.class) +class AnalysisControllerTest { + + private MockMvc mockMvc; + + @Mock + private AnalysisService analysisService; + + @BeforeEach + void setUp() { + ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json() + .findModulesViaServiceLoader(true) + .build(); + + mockMvc = MockMvcBuilders.standaloneSetup(new AnalysisController(analysisService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .build(); + + CustomUserDetails userDetails = new CustomUserDetails(1L, UserRole.ROLE_STUDENT); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("GET /api/analyses/{id}/status - 상태 조회 성공") + void getAnalysisStatus_success() throws Exception { + AnalysisStatusResponseDTO response = new AnalysisStatusResponseDTO( + 1L, + AnalysisStatus.PROCESSING, + null, + "연습 결과를 분석 중입니다.", + LocalDateTime.of(2026, 7, 24, 10, 0), + null + ); + given(analysisService.getAnalysisStatus(anyLong(), anyLong())).willReturn(response); + + mockMvc.perform(get("/api/analyses/{analysisId}/status", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.analysisId").value(1L)) + .andExpect(jsonPath("$.data.status").value("PROCESSING")) + .andExpect(jsonPath("$.data.message").value("연습 결과를 분석 중입니다.")); + } + + @Test + @DisplayName("GET /api/analyses/{id}/status - 존재하지 않는 분석이면 404") + void getAnalysisStatus_notFound() throws Exception { + given(analysisService.getAnalysisStatus(anyLong(), anyLong())) + .willThrow(new GeneralException(AnalysisErrorStatus.ANALYSIS_NOT_FOUND)); + + mockMvc.perform(get("/api/analyses/{analysisId}/status", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("ANALYSIS_404_01")); + } + + @Test + @DisplayName("GET /api/analyses/{id}/status - 다른 사용자의 분석이면 403") + void getAnalysisStatus_accessDenied() throws Exception { + willThrow(new GeneralException(AnalysisErrorStatus.ANALYSIS_ACCESS_DENIED)) + .given(analysisService).getAnalysisStatus(anyLong(), anyLong()); + + mockMvc.perform(get("/api/analyses/{analysisId}/status", 1L)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("ANALYSIS_403_01")); + } + + @Test + @DisplayName("GET /api/analyses/{id} - 결과 조회 성공") + void getAnalysisResult_success() throws Exception { + AnalysisResultResponseDTO response = new AnalysisResultResponseDTO( + 1L, + 1L, + 1, + 8, + 85, + AnalysisGrade.GOOD, + "테스트 요약", + new AnalysisResultResponseDTO.DomainScores( + new BigDecimal("80.00"), + new BigDecimal("75.50"), + new BigDecimal("90.00"), + new BigDecimal("70.00") + ), + new AnalysisResultResponseDTO.Report( + 10L, + ReportGenerationType.LLM, + LlmStatus.SUCCESS, + ContentFormat.MARKDOWN, + "리포트 본문", + "gpt-4", + "v1", + LocalDateTime.of(2026, 7, 24, 10, 0), + LocalDateTime.of(2026, 7, 24, 10, 0) + ), + null, + LocalDateTime.of(2026, 7, 24, 9, 0), + LocalDateTime.of(2026, 7, 24, 10, 0) + ); + given(analysisService.getAnalysisResult(anyLong(), anyLong())).willReturn(response); + + mockMvc.perform(get("/api/analyses/{analysisId}", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.analysisId").value(1L)) + .andExpect(jsonPath("$.data.totalScore").value(85)) + .andExpect(jsonPath("$.data.grade").value("GOOD")) + .andExpect(jsonPath("$.data.domainScores.scaleScore").value(80.00)) + .andExpect(jsonPath("$.data.report.modelName").value("gpt-4")); + } + + @Test + @DisplayName("GET /api/analyses/{id} - 완료되지 않은 분석이면 409") + void getAnalysisResult_notCompleted() throws Exception { + given(analysisService.getAnalysisResult(anyLong(), anyLong())) + .willThrow(new GeneralException(AnalysisErrorStatus.ANALYSIS_NOT_COMPLETED)); + + mockMvc.perform(get("/api/analyses/{analysisId}", 1L)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("ANALYSIS_409_01")); + } +} diff --git a/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java new file mode 100644 index 00000000..1ef8c02d --- /dev/null +++ b/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java @@ -0,0 +1,102 @@ +package com.mr.domain.analysis.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mr.domain.analysis.dto.res.AnalysisResultResponseDTO; +import com.mr.domain.analysis.entity.Analysis; +import com.mr.domain.analysis.entity.AnalysisReport; +import com.mr.domain.analysis.entity.enums.AnalysisGrade; +import com.mr.domain.analysis.entity.enums.LlmStatus; +import com.mr.domain.analysis.repository.AnalysisReportRepository; +import com.mr.domain.analysis.repository.AnalysisRepository; +import java.math.BigDecimal; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AnalysisServiceTest { + + @Mock + private AnalysisRepository analysisRepository; + + @Mock + private AnalysisReportRepository analysisReportRepository; + + private AnalysisService analysisService; + + @BeforeEach + void setUp() { + analysisService = new AnalysisService(analysisRepository, analysisReportRepository, new ObjectMapper()); + } + + private Analysis completedAnalysis(Long userId) { + Analysis analysis = Analysis.createPending(userId, 1L, 1, 8, "{}"); + analysis.startProcessing(); + analysis.complete( + 85, + AnalysisGrade.GOOD, + "테스트 요약", + new BigDecimal("80.00"), + new BigDecimal("75.50"), + new BigDecimal("90.00"), + new BigDecimal("70.00"), + null + ); + return analysis; + } + + @Test + @DisplayName("getAnalysisResult - SUCCESS 리포트가 있으면 report를 채워서 반환한다") + void getAnalysisResult_success_returnsLatestSuccessReport() { + Long analysisId = 1L; + Long userId = 1L; + Analysis analysis = completedAnalysis(userId); + AnalysisReport report = AnalysisReport.createLlmReport(analysis, "리포트 본문", "gpt-4", "v1"); + + given(analysisRepository.findById(analysisId)).willReturn(Optional.of(analysis)); + given(analysisReportRepository.findFirstByAnalysisIdAndLlmStatusOrderByCreatedAtDesc(anyLong(), any())) + .willReturn(Optional.of(report)); + + AnalysisResultResponseDTO response = analysisService.getAnalysisResult(userId, analysisId); + + ArgumentCaptor llmStatusCaptor = ArgumentCaptor.forClass(LlmStatus.class); + verify(analysisReportRepository) + .findFirstByAnalysisIdAndLlmStatusOrderByCreatedAtDesc(eq(analysisId), llmStatusCaptor.capture()); + assertThat(llmStatusCaptor.getValue()).isEqualTo(LlmStatus.SUCCESS); + + assertThat(response.report()).isNotNull(); + assertThat(response.report().content()).isEqualTo("리포트 본문"); + assertThat(response.report().llmStatus()).isEqualTo(LlmStatus.SUCCESS); + } + + @Test + @DisplayName("getAnalysisResult - SUCCESS 리포트가 없으면 report는 null이지만 나머지 필드는 정상 반환한다") + void getAnalysisResult_noSuccessReport_returnsNullReport() { + Long analysisId = 1L; + Long userId = 1L; + Analysis analysis = completedAnalysis(userId); + + given(analysisRepository.findById(analysisId)).willReturn(Optional.of(analysis)); + given(analysisReportRepository.findFirstByAnalysisIdAndLlmStatusOrderByCreatedAtDesc(anyLong(), any())) + .willReturn(Optional.empty()); + + AnalysisResultResponseDTO response = analysisService.getAnalysisResult(userId, analysisId); + + assertThat(response.report()).isNull(); + assertThat(response.totalScore()).isEqualTo(85); + assertThat(response.grade()).isEqualTo(AnalysisGrade.GOOD); + assertThat(response.domainScores().scaleScore()).isEqualByComparingTo(new BigDecimal("80.00")); + } +}