From 7c66462f5b61f1c606b9c288786573b2962b267e Mon Sep 17 00:00:00 2001 From: junmin Date: Sun, 6 Sep 2026 21:31:38 +0900 Subject: [PATCH] feat: persist and audit video analysis mode --- .../controller/AnalysisController.java | 25 +++++++-- .../analysis/dto/AnalysisCompleteRequest.java | 2 + .../domain/video/dto/VideoDetailResponse.java | 2 + .../domain/video/dto/VideoUploadResponse.java | 1 + .../backend/domain/video/entity/Video.java | 4 ++ .../domain/video/service/VideoService.java | 17 ++++-- .../AnalysisCompleteRequestContractTest.java | 26 +++++++++ .../VideoAnalysisModePersistenceTest.java | 54 +++++++++++++++++++ 8 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/rallytrack/backend/domain/analysis/dto/AnalysisCompleteRequestContractTest.java create mode 100644 src/test/java/com/rallytrack/backend/domain/video/repository/VideoAnalysisModePersistenceTest.java diff --git a/src/main/java/com/rallytrack/backend/domain/analysis/controller/AnalysisController.java b/src/main/java/com/rallytrack/backend/domain/analysis/controller/AnalysisController.java index 9cac2f6..8379230 100644 --- a/src/main/java/com/rallytrack/backend/domain/analysis/controller/AnalysisController.java +++ b/src/main/java/com/rallytrack/backend/domain/analysis/controller/AnalysisController.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +import java.util.Objects; @Tag(name = "Analysis", description = "분석 리포트 API") @RestController @@ -38,8 +39,22 @@ public ResponseEntity> getReport( public ResponseEntity> analysisComplete( @RequestBody AnalysisCompleteRequest request) { analysisService.saveResult(request); - slackNotifier.notify("✅ 분석 결과 저장 — videoId=" + request.getVideoId() - + ", totalHits=" + request.getTotalHits()); + + String storedMode = videoRepository.findById(request.getVideoId()) + .map(Video::getAnalysisMode) + .orElse(null); + String callbackMode = request.getAnalysisMode(); + String modeCheck = callbackMode == null + ? "NOT_REPORTED" + : Objects.equals(storedMode, callbackMode) ? "MATCH" : "MISMATCH"; + String completionLog = "✅ 분석 결과 저장 — videoId=" + request.getVideoId() + + ", storedMode=" + storedMode + + ", callbackMode=" + callbackMode + + ", modeCheck=" + modeCheck + + ", strokeSchemes=" + request.getStrokeClassSchemes() + + ", totalHits=" + request.getTotalHits(); + System.out.println(completionLog); + slackNotifier.notify(completionLog); return ResponseEntity.ok(ApiResponse.success("분석 결과 저장 완료", null)); } @@ -56,7 +71,11 @@ public ResponseEntity> analysisFail( }); System.err.println("[분석 실패] videoId=" + videoId + ", error=" + error); - slackNotifier.notify("❌ 분석 실패 — videoId=" + videoId + "\n에러: " + error); + String storedMode = videoRepository.findById(videoId) + .map(Video::getAnalysisMode) + .orElse(null); + slackNotifier.notify("❌ 분석 실패 — videoId=" + videoId + + ", storedMode=" + storedMode + "\n에러: " + error); return ResponseEntity.ok(ApiResponse.success("실패 상태 업데이트 완료", null)); } } diff --git a/src/main/java/com/rallytrack/backend/domain/analysis/dto/AnalysisCompleteRequest.java b/src/main/java/com/rallytrack/backend/domain/analysis/dto/AnalysisCompleteRequest.java index c1e13ca..5b87853 100644 --- a/src/main/java/com/rallytrack/backend/domain/analysis/dto/AnalysisCompleteRequest.java +++ b/src/main/java/com/rallytrack/backend/domain/analysis/dto/AnalysisCompleteRequest.java @@ -24,6 +24,8 @@ public class AnalysisCompleteRequest { private List hitsData; private String skeletonVideoUrl; private String minimapVideoUrl; + private String analysisMode; + private List strokeClassSchemes; @JsonProperty("rallyResults") private List rallyResults; diff --git a/src/main/java/com/rallytrack/backend/domain/video/dto/VideoDetailResponse.java b/src/main/java/com/rallytrack/backend/domain/video/dto/VideoDetailResponse.java index 970d3b1..432878e 100644 --- a/src/main/java/com/rallytrack/backend/domain/video/dto/VideoDetailResponse.java +++ b/src/main/java/com/rallytrack/backend/domain/video/dto/VideoDetailResponse.java @@ -27,6 +27,8 @@ public static class VideoInfo { private String thumbnailUrl; // 초 단위 정수 — 프론트에서 "M:SS" 포맷 변환 또는 표시 용도 private Integer durationSeconds; + // "pro" | "amateur". null이면 컬럼 추가 전 업로드된 기존 영상 + private String analysisMode; } @Getter diff --git a/src/main/java/com/rallytrack/backend/domain/video/dto/VideoUploadResponse.java b/src/main/java/com/rallytrack/backend/domain/video/dto/VideoUploadResponse.java index 8ecf714..32baddc 100644 --- a/src/main/java/com/rallytrack/backend/domain/video/dto/VideoUploadResponse.java +++ b/src/main/java/com/rallytrack/backend/domain/video/dto/VideoUploadResponse.java @@ -12,4 +12,5 @@ public class VideoUploadResponse { private String title; private String uploadDate; private String status; + private String analysisMode; } diff --git a/src/main/java/com/rallytrack/backend/domain/video/entity/Video.java b/src/main/java/com/rallytrack/backend/domain/video/entity/Video.java index 697cb8a..50e1dcc 100644 --- a/src/main/java/com/rallytrack/backend/domain/video/entity/Video.java +++ b/src/main/java/com/rallytrack/backend/domain/video/entity/Video.java @@ -80,6 +80,10 @@ public class Video { @Column(name = "duration_seconds") private Integer durationSeconds; + // "pro" | "amateur". NULL은 컬럼 추가 전 업로드된 기존 영상이라 모드를 알 수 없다는 뜻이다. + @Column(name = "analysis_mode", length = 16) + private String analysisMode; + @Column(name = "video_status") @Builder.Default private String videoStatus = "PROCESSING"; diff --git a/src/main/java/com/rallytrack/backend/domain/video/service/VideoService.java b/src/main/java/com/rallytrack/backend/domain/video/service/VideoService.java index 5dc05ce..8e291f0 100644 --- a/src/main/java/com/rallytrack/backend/domain/video/service/VideoService.java +++ b/src/main/java/com/rallytrack/backend/domain/video/service/VideoService.java @@ -104,15 +104,18 @@ public VideoUploadResponse uploadVideo(Long userId, MultipartFile videoFile, : null) .durationSeconds( durationSeconds != null && durationSeconds > 0 ? durationSeconds : null) + .analysisMode(analysisMode) .build(); Video saved = videoRepository.save(video); - slackNotifier.notify(String.format( - "📤 영상 업로드 — videoId=%d, 제목: %s, mode=%s, 크기: %.1fMB, 재생시간: %ds", + String uploadLog = String.format( + "📤 영상 업로드 — videoId=%d, 제목: %s, analysisMode=%s (DB 저장), 크기: %.1fMB, 재생시간: %ds", saved.getVideoId(), title, analysisMode, videoFile.getSize() / 1024.0 / 1024.0, - durationSeconds != null ? durationSeconds : 0)); + durationSeconds != null ? durationSeconds : 0); + System.out.println(uploadLog); + slackNotifier.notify(uploadLog); // AI 서버 호출 try { @@ -157,8 +160,10 @@ public VideoUploadResponse uploadVideo(Long userId, MultipartFile videoFile, aiServerUrl + "/analyze", analyzeRequest, String.class); - slackNotifier.notify("🚀 AI 분석 요청 전송 완료 — videoId=" + saved.getVideoId() - + " → " + aiServerUrl); + String requestLog = "🚀 AI 분석 요청 전송 완료 — videoId=" + saved.getVideoId() + + ", analysisMode=" + analysisMode + " → " + aiServerUrl; + System.out.println(requestLog); + slackNotifier.notify(requestLog); } catch (Exception e) { e.printStackTrace(); // AI 서버 호출 실패해도 업로드 자체는 성공으로 처리 @@ -173,6 +178,7 @@ public VideoUploadResponse uploadVideo(Long userId, MultipartFile videoFile, .title(saved.getTitle()) .uploadDate(saved.getUploadDate().format(DateTimeFormatter.ISO_LOCAL_DATE)) .status(saved.getVideoStatus()) + .analysisMode(saved.getAnalysisMode()) .build(); } @@ -244,6 +250,7 @@ public VideoDetailResponse getVideoDetail(Long videoId) { ? s3Service.generatePresignedUrl(video.getThumbnailUrl()) : null) .durationSeconds(video.getDurationSeconds()) + .analysisMode(video.getAnalysisMode()) .build()) .matchSummary(matchSummary) .timelineEvents(eventDtos) diff --git a/src/test/java/com/rallytrack/backend/domain/analysis/dto/AnalysisCompleteRequestContractTest.java b/src/test/java/com/rallytrack/backend/domain/analysis/dto/AnalysisCompleteRequestContractTest.java new file mode 100644 index 0000000..aec268c --- /dev/null +++ b/src/test/java/com/rallytrack/backend/domain/analysis/dto/AnalysisCompleteRequestContractTest.java @@ -0,0 +1,26 @@ +package com.rallytrack.backend.domain.analysis.dto; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AnalysisCompleteRequestContractTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void readsAnalysisModeAndActualClassifierSchemesFromAiCallback() throws Exception { + AnalysisCompleteRequest request = objectMapper.readValue(""" + { + "videoId": 10, + "analysisMode": "pro", + "strokeClassSchemes": ["9class"], + "totalHits": 6 + } + """, AnalysisCompleteRequest.class); + + assertThat(request.getAnalysisMode()).isEqualTo("pro"); + assertThat(request.getStrokeClassSchemes()).containsExactly("9class"); + } +} diff --git a/src/test/java/com/rallytrack/backend/domain/video/repository/VideoAnalysisModePersistenceTest.java b/src/test/java/com/rallytrack/backend/domain/video/repository/VideoAnalysisModePersistenceTest.java new file mode 100644 index 0000000..657c1df --- /dev/null +++ b/src/test/java/com/rallytrack/backend/domain/video/repository/VideoAnalysisModePersistenceTest.java @@ -0,0 +1,54 @@ +package com.rallytrack.backend.domain.video.repository; + +import com.rallytrack.backend.domain.user.entity.User; +import com.rallytrack.backend.domain.user.repository.UserRepository; +import com.rallytrack.backend.domain.video.entity.Video; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class VideoAnalysisModePersistenceTest { + + @Autowired + private VideoRepository videoRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EntityManager entityManager; + + @Test + void persistsProAndAmateurModesSeparately() { + User user = userRepository.save(User.builder() + .email("mode-test@example.com") + .password("test") + .nickname("mode-test") + .build()); + + Long proId = videoRepository.save(Video.builder() + .title("pro video") + .analysisMode("pro") + .user(user) + .build()).getVideoId(); + Long amateurId = videoRepository.save(Video.builder() + .title("amateur video") + .analysisMode("amateur") + .user(user) + .build()).getVideoId(); + + videoRepository.flush(); + entityManager.clear(); + + assertThat(videoRepository.findById(proId).orElseThrow().getAnalysisMode()) + .isEqualTo("pro"); + assertThat(videoRepository.findById(amateurId).orElseThrow().getAnalysisMode()) + .isEqualTo("amateur"); + } +}