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
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.wisoft.prepair.prepair_api.external.storage;

final class FileExtensions {

private static final String DEFAULT = ".tmp";

private FileExtensions() {}

static String extract(String filename) {
if (filename == null || filename.isBlank()) return DEFAULT;
int idx = filename.lastIndexOf('.');
if (idx < 0 || idx == filename.length() - 1) return DEFAULT;
return filename.substring(idx);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.wisoft.prepair.prepair_api.external.storage;

import io.wisoft.prepair.prepair_api.common.exception.BusinessException;
import io.wisoft.prepair.prepair_api.common.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

@Slf4j
@Component
public class LocalFileStorage {

public Path save(MultipartFile file) {
try {
Path path = Files.createTempFile("video-", FileExtensions.extract(file.getOriginalFilename()));
file.transferTo(path);
return path;
} catch (Exception e) {
log.error("[로컬파일] 저장 실패 - filename: {}", file.getOriginalFilename(), e);
throw new BusinessException(ErrorCode.INTERNAL_ERROR);
}
Comment on lines +22 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

포괄적인 Exception 대신 더 구체적인 IOException을 catch하는 것이 좋습니다. file.transferTo()Files.createTempFile()은 주로 IOException을 발생시키며, 이는 예측 가능한 I/O 오류입니다. IllegalStateException과 같은 다른 런타임 예외는 애플리케이션의 전역 예외 처리기에서 처리되도록 두는 것이 더 나은 설계일 수 있습니다. 이렇게 하면 예외 처리 로직이 더 명확해지고 의도치 않은 예외를 숨기는 것을 방지할 수 있습니다.

Suggested change
} catch (Exception e) {
log.error("[로컬파일] 저장 실패 - filename: {}", file.getOriginalFilename(), e);
throw new BusinessException(ErrorCode.INTERNAL_ERROR);
}
} catch (IOException e) {
log.error("[로컬파일] 저장 실패 - filename: {}", file.getOriginalFilename(), e);
throw new BusinessException(ErrorCode.INTERNAL_ERROR);
}

}

public void delete(Path path) {
if (path == null) return;
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("[로컬파일] 삭제 실패 - path: {}", path, e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;

@Slf4j
@Component
@RequiredArgsConstructor
public class FileUploader {
public class S3FileStorage {

private final S3Client s3Client;

Expand All @@ -25,8 +29,13 @@ public class FileUploader {
@Value("${cloud.aws.s3.endpoint}")
private String endpoint;

public String upload(Path videoPath, String contentType, String email) {
String extension = getExtension(videoPath.getFileName().toString());
@Retryable(
value = { SdkClientException.class, S3Exception.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public String save(Path videoPath, String contentType, String email) {
String extension = FileExtensions.extract(videoPath.getFileName().toString());
String key = "interview-video/" + email + "/" + LocalDate.now() + "/" + UUID.randomUUID() + extension;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

여러 문자열을 + 연산자로 연결하는 것보다 String.format()을 사용하면 코드가 더 읽기 쉽고 유지보수하기 좋아집니다. 특히 S3 키와 같이 구조가 정해진 문자열을 만들 때 가독성 향상에 도움이 됩니다.

Suggested change
String key = "interview-video/" + email + "/" + LocalDate.now() + "/" + UUID.randomUUID() + extension;
String key = String.format("interview-video/%s/%s/%s%s", email, LocalDate.now(), UUID.randomUUID(), extension);


s3Client.putObject(PutObjectRequest.builder()
Expand All @@ -39,11 +48,4 @@ public String upload(Path videoPath, String contentType, String email) {

return endpoint + "/" + bucket + "/" + key;
}

private String getExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return ".webm";
}
return filename.substring(filename.lastIndexOf("."));
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package io.wisoft.prepair.prepair_api.interview.answer.event;

import java.nio.file.Path;
import java.util.UUID;

public record AllAnalysisCompletedEvent(UUID answerId, boolean hasFailed, Path videoPath) {
}
public record AllAnalysisCompletedEvent(UUID answerId, boolean hasFailed) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
Expand All @@ -48,7 +45,6 @@ public class AllAnalysisCompletedHandler {
@EventListener
public void handle(AllAnalysisCompletedEvent event) {
UUID answerId = event.answerId();
deleteTempFile(event.videoPath());

if (event.hasFailed()) {
log.error("[종합평가] 분석 실패로 종합평가 생략 - answerId: {}", answerId);
Expand Down Expand Up @@ -225,15 +221,6 @@ private void failSession(UUID answerId, String message) {
sseEmitterManager.complete(session.getId());
}

private void deleteTempFile(Path videoPath) {
if (videoPath == null) return;
try {
Files.deleteIfExists(videoPath);
} catch (IOException e) {
log.warn("[임시파일] 삭제 실패 - path: {}", videoPath, e);
}
}

private record AnalysisFeedbacks(
InterviewFeedback stt,
InterviewFeedback video
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.wisoft.prepair.prepair_api.interview.answer.event;

import io.wisoft.prepair.prepair_api.external.storage.LocalFileStorage;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
Expand All @@ -17,41 +18,67 @@
public class AnalysisCompletionTracker {

private final ApplicationEventPublisher eventPublisher;
private final LocalFileStorage localFileStorage;

private static final int ANALYSIS_TASKS = 2;
private static final int TOTAL_TASKS = 3;

private final ConcurrentMap<UUID, AtomicInteger> completionMap = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, AtomicBoolean> failureMap = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, Path> videoPathMap = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, TrackingState> stateMap = new ConcurrentHashMap<>();

public void init(UUID answerId, Path videoPath) {
completionMap.put(answerId, new AtomicInteger(0));
failureMap.put(answerId, new AtomicBoolean(false));
videoPathMap.put(answerId, videoPath);
stateMap.put(answerId, new TrackingState(videoPath));
}

public void completeAnalysis(UUID answerId) {
finishAnalysis(answerId, false);
}

public void complete(UUID answerId) {
finish(answerId, false);
public void failAnalysis(UUID answerId) {
finishAnalysis(answerId, true);
}

public void fail(UUID answerId) {
finish(answerId, true);
public void completeS3(UUID answerId) {
finishTask(answerId);
}

private void finish(UUID answerId, boolean failed) {
AtomicInteger counter = completionMap.get(answerId);
if (counter == null) return;
public void failS3(UUID answerId) {
finishTask(answerId);
}

private void finishAnalysis(UUID answerId, boolean failed) {
TrackingState state = stateMap.get(answerId);
if (state == null) return;

if (failed) state.analysisFailed.set(true);

if (failed) {
failureMap.get(answerId).set(true);
int analysisCount = state.analysisCount.incrementAndGet();
if (analysisCount == ANALYSIS_TASKS) {
eventPublisher.publishEvent(
new AllAnalysisCompletedEvent(answerId, state.analysisFailed.get())
);
}
finishTask(answerId);
}

private void finishTask(UUID answerId) {
TrackingState state = stateMap.get(answerId);
if (state == null) return;

int totalCount = state.totalCount.incrementAndGet();
if (totalCount == TOTAL_TASKS) {
stateMap.remove(answerId);
localFileStorage.delete(state.videoPath);
}
}

int count = counter.incrementAndGet();
private static final class TrackingState {
final AtomicInteger analysisCount = new AtomicInteger(0);
final AtomicInteger totalCount = new AtomicInteger(0);
final AtomicBoolean analysisFailed = new AtomicBoolean(false);
final Path videoPath;

if (count == TOTAL_TASKS) {
boolean hasFailed = failureMap.remove(answerId).get();
completionMap.remove(answerId);
Path videoPath = videoPathMap.remove(answerId);
eventPublisher.publishEvent(new AllAnalysisCompletedEvent(answerId, hasFailed, videoPath));
TrackingState(Path videoPath) {
this.videoPath = videoPath;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,45 +1,48 @@
package io.wisoft.prepair.prepair_api.interview.answer.service;
package io.wisoft.prepair.prepair_api.interview.answer.event;

import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackResult;
import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackDetail;
import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion;
import io.wisoft.prepair.prepair_api.interview.answer.entity.FeedbackType;
import io.wisoft.prepair.prepair_api.common.exception.BusinessException;
import io.wisoft.prepair.prepair_api.common.exception.ErrorCode;
import io.wisoft.prepair.prepair_api.external.storage.S3FileStorage;
import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackDetail;
import io.wisoft.prepair.prepair_api.interview.answer.dto.FeedbackResult;
import io.wisoft.prepair.prepair_api.interview.answer.entity.FeedbackType;
import io.wisoft.prepair.prepair_api.interview.answer.service.AnswerPersistenceService;
import io.wisoft.prepair.prepair_api.interview.answer.service.FeedbackGenerator;
import io.wisoft.prepair.prepair_api.interview.answer.service.SpeechToTextService;
import io.wisoft.prepair.prepair_api.interview.answer.service.VideoFrameAnalysisService;
import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion;
import io.wisoft.prepair.prepair_api.interview.question.repository.QuestionRepository;
import io.wisoft.prepair.prepair_api.interview.answer.event.AnalysisCompletionTracker;
import io.wisoft.prepair.prepair_api.external.storage.FileUploader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.stereotype.Component;

import java.nio.file.Path;
import java.util.UUID;

@Slf4j
@Service
@Component
@RequiredArgsConstructor
public class VideoAnswerProcessor {

private final AnswerPersistenceService answerPersistenceService;
private final SpeechToTextService speechToTextService;
private final VideoFrameAnalysisService videoAnalysisService;
private final QuestionRepository questionRepository;
private final FileUploader fileUploader;
private final S3FileStorage s3FileStorage;
private final FeedbackGenerator feedbackGenerator;
private final AnalysisCompletionTracker completionTracker;

@Async("videoTaskExecutor")
public void uploadToS3(final UUID answerId, final Path videoPath, final String contentType, final String email) {
try {
String mediaUrl = fileUploader.upload(videoPath, contentType, email);
String mediaUrl = s3FileStorage.save(videoPath, contentType, email);
answerPersistenceService.updateMediaUrl(answerId, mediaUrl);
log.info("[VIDEO-S3] 업로드 완료 - answerId: {}", answerId);
completionTracker.complete(answerId);
completionTracker.completeS3(answerId);
} catch (Exception e) {
log.error("[VIDEO-S3] 업로드 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e);
completionTracker.fail(answerId);
completionTracker.failS3(answerId);
}
}

Expand All @@ -58,10 +61,10 @@ public void analyzeSTT(final UUID answerId, final UUID questionId, final UUID me
answerPersistenceService.saveVideoFeedback(answerId, result, detail, FeedbackType.STT);

log.info("[VIDEO-STT] 분석 완료 - answerId: {}", answerId);
completionTracker.complete(answerId);
completionTracker.completeAnalysis(answerId);
} catch (Exception e) {
log.error("[VIDEO-STT] 분석 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e);
completionTracker.fail(answerId);
completionTracker.failAnalysis(answerId);
}
}

Expand All @@ -74,10 +77,10 @@ public void analyzeVideo(final UUID answerId, final Path videoPath) {
answerPersistenceService.saveVideoFeedback(answerId, result, detail, FeedbackType.VIDEO);

log.info("[VIDEO-ANALYSIS] 분석 완료 - answerId: {}", answerId);
completionTracker.complete(answerId);
completionTracker.completeAnalysis(answerId);
} catch (Exception e) {
log.error("[VIDEO-ANALYSIS] 분석 실패 - answerId: {}, error: {}", answerId, e.getMessage(), e);
completionTracker.fail(answerId);
completionTracker.failAnalysis(answerId);
}
}
}
Loading
Loading