diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/controller/AudioAnalysisController.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/controller/AudioAnalysisController.java index 2979e1cc..e6bdec15 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/controller/AudioAnalysisController.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/controller/AudioAnalysisController.java @@ -1,12 +1,14 @@ package repit.repit_api_server.domain.userdata.analysis.controller; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import repit.repit_api_server.domain.userdata.analysis.dto.request.AudioAnalysisCallbackRequest; import repit.repit_api_server.domain.userdata.analysis.dto.response.AudioAnalysisResponse; @@ -33,4 +35,12 @@ public ApiResponse get(@AuthenticationPrincipal AuthUser @PathVariable Long interviewId) { return ApiResponse.success(audioAnalysisService.get(authUser.id(), interviewId)); } + + /** 음성 분석을 다시 요청한다. 면접당 2번까지, 결과가 없거나 다시 하면 나아질 실패가 있는 답변만 다시 보낸다. */ + @PostMapping("/api/interviews/{interviewId}/audio-analysis/retry") + @ResponseStatus(HttpStatus.ACCEPTED) + public ApiResponse retry(@AuthenticationPrincipal AuthUser authUser, + @PathVariable Long interviewId) { + return ApiResponse.success(audioAnalysisService.retry(authUser.id(), interviewId)); + } } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/dto/response/AudioAnalysisResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/dto/response/AudioAnalysisResponse.java index 59a66483..ff198bdf 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/dto/response/AudioAnalysisResponse.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/dto/response/AudioAnalysisResponse.java @@ -6,13 +6,14 @@ /** * 웹에 내려주는 면접 음성 분석 상태와 답변별 지표. 상태 이름은 웹 계약(docs/interview-recording.md)을 따른다. * - * @param status 면접 전체 상태. 웹은 PENDING일 때만 다시 조회한다 - * @param answers 녹음이 있는 답변, 질문 진행 순서대로 + * @param status 면접 전체 상태. 웹은 PENDING일 때만 다시 조회한다 + * @param answers 녹음이 있는 답변, 질문 진행 순서대로 + * @param canRetry 지금 음성 분석을 다시 요청할 수 있는지 */ -public record AudioAnalysisResponse(Status status, List answers) { +public record AudioAnalysisResponse(Status status, List answers, boolean canRetry) { public enum Status { - /** 아직 요청 전이거나 분석 중이다. 이것만 진행 중이고 나머지는 끝난 상태다. */ + /** 아직 요청 전이거나 분석 중이다. 다시 분석하는 중도 여기다. 이것만 진행 중이고 나머지는 끝난 상태다. */ PENDING, READY, /** 일부만 결과가 있다. 진행 중이 아니라 끝난 상태다. */ @@ -25,8 +26,8 @@ public enum Status { /** * @param questionId 지금의 질문 PK. 피드백 문항(FeedbackItemResponse)의 questionId와 같다 - * @param requestId 이 답변의 상태·지표를 낸 분석 요청 id. 결과가 있으면 그 결과의 요청, 없으면 이 녹음을 실은 - * 가장 나중 요청이다. 아직 요청 전이면 null + * @param requestId 이 답변의 상태·지표를 낸 분석 요청 id. 결과가 있으면 그 결과의 요청, 없거나 다시 분석하는 + * 중이면 이 녹음을 실은 가장 나중 요청이다. 아직 요청 전이면 null * @param timing 말 속도·침묵·속도 안정성. 분석 서버가 보낸 {status, data, error} 그대로 * @param fluency 간투사·반복 후보 등. 분석 서버가 보낸 {status, data, error} 그대로 */ diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/AudioAnalysisService.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/AudioAnalysisService.java index a3d1b536..2e5c5c6e 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/AudioAnalysisService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/AudioAnalysisService.java @@ -24,6 +24,7 @@ import repit.repit_api_server.domain.userdata.answer.repository.AnswerRepository; import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackDispatchEntity; import repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackDispatchKind; +import repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackDispatchStatus; import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackDispatchRepository; import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; @@ -66,6 +67,9 @@ * 새 작업을 만들지 않고 기존 작업을 202로 돌려주므로, 응답을 받지 못한 요청은 같은 요청 id로 다시 보내야 * 같은 답변이 두 번 분석되지 않는다. 반대로 분석 서버가 거절한 요청은 그 자리에서 닫아, 그 녹음이 새 요청 * id로 다시 나가게 한다 — 서명이 만료된 주소는 새 주소와 새 요청 id로만 살릴 수 있다. + * + *

사용자는 면접당 {@value #MAX_USER_RETRIES}번 다시 요청할 수 있다({@link #retry}). 다시 보내는 것은 결과가 없거나 + * 다시 하면 나아질 실패가 있는 답변뿐이다({@link #worthRetrying}). */ @Service @RequiredArgsConstructor @@ -78,6 +82,8 @@ public class AudioAnalysisService { static final int MAX_RECORDINGS_PER_REQUEST = 12; // 분석 서버가 받는 녹음 하나의 크기 상한. 넘는 것이 하나라도 실리면 요청 전체가 422다. static final long MAX_SOURCE_BYTES = 100_000_000L; + // 사용자가 다시 요청할 수 있는 횟수. 영상의 영상당 2회와 같다. + static final int MAX_USER_RETRIES = 2; private static final String TIMED_OUT = "음성 분석 결과를 제때 받지 못했습니다."; private static final String NOT_ACKNOWLEDGED = "음성 분석 접수를 확인하지 못해 새 요청으로 다시 맡깁니다."; @@ -159,20 +165,132 @@ public void requestForFinishedInterview(Long interviewId) { *

  • 결과는 녹음 id로 찾고, 여럿이면 요청 순서(analysis_id)로 가장 나중 것을 쓴다. 도착 순서로 고르면 늦게 온 옛 * 결과가 새 결과를 가린다.
  • *
  • 결과 없는 녹음이 앞으로 요청될지는 음성 분석 대기 행으로 정한다({@link #toBeRequested}).
  • + *
  • 결과보다 나중 요청이 그 녹음을 다시 분석하는 중이면 진행 중으로 보인다({@link #reanalyzing}).
  • * */ public AudioAnalysisResponse get(Long userId, Long interviewId) { + InterviewEntity interview = ownedInterview(userId, interviewId); + FeedbackDispatchEntity dispatch = audioDispatch(interviewId); + Status toBeRequested = toBeRequested(dispatch); + List answers = answers(interviewId, toBeRequested); + if (answers.isEmpty()) { + // 음성이 아직 올라오는 중일 수 있다. 요청할 때가 지났는데도 없으면 받을 결과가 없다(텍스트 면접 등). + return new AudioAnalysisResponse(toBeRequested == Status.PENDING ? Status.PENDING : Status.UNAVAILABLE, + List.of(), false); + } + return new AudioAnalysisResponse(overallStatus(answers), answers, + retryBlockedReason(interview, dispatch, answers) == null); + } + + /** + * 사용자가 음성 분석을 다시 요청한다. + * + *

    {@link #worthRetrying} 답변의 녹음만 새 요청 id로 요청 자리를 만들고, 음성 분석 대기 행을 기다리는 자리로 + * 돌린다. 보내는 것은 대기 스윕이 한다 — 요청 경로가 작업 id 없는 요청을 그 요청 id로 보내므로 따로 보내는 길을 + * 두지 않는다. 서명 URL도 그때 새로 만든다. + * + *

    둘을 한 트랜잭션으로 묶는다. 대기 행만 돌면 횟수만 쓰고 아무것도 보내지 않고, 요청 자리만 남으면 아무도 + * 보내지 않는다. + */ + @Transactional + public AudioAnalysisResponse retry(Long userId, Long interviewId) { + InterviewEntity interview = ownedInterview(userId, interviewId); + FeedbackDispatchEntity dispatch = audioDispatch(interviewId); + List answers = answers(interviewId, toBeRequested(dispatch)); + String blocked = retryBlockedReason(interview, dispatch, answers); + if (blocked != null) { + throw BusinessException.conflict(blocked); + } + if (dispatchRepository.reopenForUserRetry(interviewId, FeedbackDispatchKind.AUDIO_ANALYSIS, + MAX_USER_RETRIES) == 0) { + // 연달아 누른 다른 요청이 먼저 받아들여졌다. + throw BusinessException.conflict("이미 분석 중입니다."); + } + + List recordingIds = answers.stream() + .filter(AudioAnalysisService::worthRetrying) + .map(answer -> Long.valueOf(answer.recordingId())) + .toList(); + for (int from = 0; from < recordingIds.size(); from += MAX_RECORDINGS_PER_REQUEST) { + analysisRepository.save(newRequest(interview, + recordingIds.subList(from, Math.min(from + MAX_RECORDINGS_PER_REQUEST, recordingIds.size())))); + } + log.info("사용자가 음성 분석을 다시 요청했습니다. interviewId={}, recordings={}", interviewId, recordingIds); + return get(userId, interviewId); + } + + /** + * 다시 요청을 받을 수 없는 이유. 받을 수 있으면 null. + * + *

    정상 결과도 말더듬 검출이 없어 늘 PARTIAL이라, 상태만으로는 열지 않고 다시 보낼 답변이 있을 때만 연다. + */ + String retryBlockedReason(InterviewEntity interview, FeedbackDispatchEntity dispatch, + List answers) { + if (dispatch == null) { + return "아직 분석을 시작하지 않았습니다."; + } + if (dispatch.getStatus() == FeedbackDispatchStatus.WAITING || dispatch.getStatus() == FeedbackDispatchStatus.SENDING + || answers.stream().anyMatch(answer -> answer.status() == Status.PENDING)) { + return "이미 분석 중입니다."; + } + if (interview.getSessionId() == null || interview.getSessionId().isBlank()) { + return "면접에 채팅 세션이 없어 음성 분석을 할 수 없습니다."; + } + if (answers.stream().noneMatch(AudioAnalysisService::worthRetrying)) { + return "다시 분석할 답변이 없습니다."; + } + if (dispatch.getUserRetryCount() >= MAX_USER_RETRIES) { + return "음성 분석은 면접당 " + MAX_USER_RETRIES + "번까지 다시 요청할 수 있습니다."; + } + return null; + } + + /** + * 다시 보내면 결과가 나아질 수 있는 답변. + * + *

      + *
    • 결과가 없다(지표도 실패 사유도 없다) — 요청이 결과 없이 닫혔거나, 끝난 요청에 이 녹음의 결과만 빠졌거나, + * 요청을 마친 뒤 올라왔다.
    • + *
    • 파일·timing·fluency 중 실패한 곳을 분석 서버가 retryable=true로 알렸다 — 원본 다운로드 실패, 서명 만료, + * 외부 서비스 장애 등.
    • + *
    + * 라인의 partial(말더듬 검출 미제공, 무발화·한글 없음)과 retryable=false인 실패(전사할 말 없음, 파일 손상 등)는 + * 같은 음성이면 같은 결과가 나오므로 넣지 않는다. + */ + static boolean worthRetrying(AudioAnalysisResponse.Answer answer) { + if (answer.status() == Status.PENDING) { + return false; + } + if (answer.timing() == null && answer.fluency() == null && answer.error() == null) { + return true; + } + return (answer.error() != null && Boolean.TRUE.equals(answer.error().retryable())) + || retryableLine(answer.timing()) || retryableLine(answer.fluency()); + } + + private static boolean retryableLine(Map line) { + return line != null && Boolean.TRUE.equals(errorField(line.get("error"), "retryable", Boolean.class)); + } + + private InterviewEntity ownedInterview(Long userId, Long interviewId) { InterviewEntity interview = interviewRepository.findById(interviewId) .orElseThrow(() -> BusinessException.notFound("면접을 찾을 수 없습니다")); if (!Objects.equals(userId, interview.getUserId())) { throw BusinessException.forbidden("본인의 면접만 볼 수 있습니다."); } - Status toBeRequested = toBeRequested(interviewId); + return interview; + } + + private FeedbackDispatchEntity audioDispatch(Long interviewId) { + return dispatchRepository.findByInterviewIdAndKind(interviewId, FeedbackDispatchKind.AUDIO_ANALYSIS) + .orElse(null); + } + + /** 녹음이 있는 답변마다 지금 보여 줄 상태와 지표. */ + private List answers(Long interviewId, Status toBeRequested) { Collection loaded = analyzable(interviewId).values(); if (loaded.isEmpty()) { - // 음성이 아직 올라오는 중일 수 있다. 요청할 때가 지났는데도 없으면 받을 결과가 없다(텍스트 면접 등). - return new AudioAnalysisResponse(toBeRequested == Status.PENDING ? Status.PENDING : Status.UNAVAILABLE, - List.of()); + return List.of(); } // 요청 순서대로 덮어 쓰므로 녹음마다 가장 나중 요청·결과가 남는다. @@ -194,16 +312,26 @@ public AudioAnalysisResponse get(Long userId, Long interviewId) { .forEach(result -> resultByRecording.put(result.getRecordingId(), result)); } - List answers = loaded.stream() + return loaded.stream() .map(one -> { Long recordingId = one.recording().getRecordingId(); AudioAnalysisResultEntity result = resultByRecording.get(recordingId); - return result == null - ? withoutResult(one, requestByRecording.get(recordingId), toBeRequested) + AudioAnalysisEntity latestRequest = requestByRecording.get(recordingId); + return result == null || reanalyzing(latestRequest, result, toBeRequested) + ? withoutResult(one, latestRequest, toBeRequested) : withResult(one, result, requestById.get(result.getAnalysisId())); }) .toList(); - return new AudioAnalysisResponse(overallStatus(answers), answers); + } + + /** + * 결과를 낸 요청보다 나중 요청이 그 녹음을 다시 분석하는 중인지. 그동안은 앞선 결과 대신 진행 중으로 보여, + * 웹이 조회를 멈추지 않고 새 결과까지 받게 한다. + */ + private static boolean reanalyzing(AudioAnalysisEntity latestRequest, AudioAnalysisResultEntity result, + Status toBeRequested) { + return latestRequest != null && latestRequest.getAnalysisId() > result.getAnalysisId() + && statusWithoutResult(latestRequest, toBeRequested) == Status.PENDING; } private AudioAnalysisResponse.Answer withResult(AnswerRecording loaded, AudioAnalysisResultEntity result, @@ -222,15 +350,19 @@ private AudioAnalysisResponse.Answer withResult(AnswerRecording loaded, AudioAna */ private static AudioAnalysisResponse.Answer withoutResult(AnswerRecording loaded, AudioAnalysisEntity latestRequest, Status toBeRequested) { - Status status = latestRequest == null ? toBeRequested : switch (latestRequest.getStatus()) { + return new AudioAnalysisResponse.Answer(String.valueOf(loaded.questionId()), String.valueOf(loaded.answerId()), + String.valueOf(loaded.recording().getRecordingId()), + latestRequest == null ? null : latestRequest.getRequestId(), + statusWithoutResult(latestRequest, toBeRequested), null, null, null, null); + } + + private static Status statusWithoutResult(AudioAnalysisEntity latestRequest, Status toBeRequested) { + return latestRequest == null ? toBeRequested : switch (latestRequest.getStatus()) { case PENDING -> Status.PENDING; case FAILED -> toBeRequested == Status.PENDING ? Status.PENDING : Status.FAILED; // 끝난 요청인데 이 녹음의 결과만 빠졌다. 다시 요청되지 않으므로 받을 결과가 없다. case READY, PARTIAL, UNAVAILABLE -> Status.UNAVAILABLE; }; - return new AudioAnalysisResponse.Answer(String.valueOf(loaded.questionId()), String.valueOf(loaded.answerId()), - String.valueOf(loaded.recording().getRecordingId()), - latestRequest == null ? null : latestRequest.getRequestId(), status, null, null, null, null); } /** @@ -238,20 +370,20 @@ private static AudioAnalysisResponse.Answer withoutResult(AnswerRecording loaded * *
      *
    • 행이 없다 — 면접 기록이 아직 오지 않았다. 기록이 오면 행이 생기고 요청이 나간다.
    • - *
    • WAITING·SENDING — 음성이 모이기를 기다리거나 요청하는 중이다.
    • + *
    • WAITING·SENDING — 음성이 모이기를 기다리거나 요청하는 중이다(다시 요청을 받은 뒤 포함).
    • *
    • DONE — 요청을 마쳤다. 그 뒤에 올라온 녹음은 이번 분석에 들지 않는다.
    • *
    • FAILED — 요청을 포기했다.
    • *
    */ - private Status toBeRequested(Long interviewId) { - return dispatchRepository.findByInterviewIdAndKind(interviewId, FeedbackDispatchKind.AUDIO_ANALYSIS) - .map(FeedbackDispatchEntity::getStatus) - .map(dispatch -> switch (dispatch) { - case WAITING, SENDING -> Status.PENDING; - case DONE -> Status.UNAVAILABLE; - case FAILED -> Status.FAILED; - }) - .orElse(Status.PENDING); + private static Status toBeRequested(FeedbackDispatchEntity dispatch) { + if (dispatch == null) { + return Status.PENDING; + } + return switch (dispatch.getStatus()) { + case WAITING, SENDING -> Status.PENDING; + case DONE -> Status.UNAVAILABLE; + case FAILED -> Status.FAILED; + }; } /** 결과 행의 status(대문자로 저장). 모르는 값은 받을 수 있는 결과가 없는 것으로 본다 — 콜백 상태와 같은 규칙이다. */ @@ -340,12 +472,13 @@ private Map analyzable(Long interviewId) { *
  • 결과를 기다리다 확인할 때가 된 요청 — 작업을 직접 조회해 결과가 있으면 가져온다. 콜백 재전송이 모두 * 실패해도 결과는 분석 서버에 남아 있다.
  • *
  • 포기 시간까지 어느 쪽도 되지 않은 요청 — 닫아서 그 녹음이 새 요청 id로 다시 나가게 한다.
  • - *
  • 닫힌(FAILED) 요청의 녹음은 아직 맡기지 않은 것으로 본다.
  • + *
  • 녹음을 실은 가장 나중 요청이 닫혔으면(FAILED) 아직 맡기지 않은 것으로 본다. 앞선 결과가 있어도 + * 사용자가 다시 요청한 요청이 닫히면 그 녹음은 다시 나간다.
  • * */ private Set settleEarlierRequests(InterviewEntity interview, Map analyzable, String callbackUrl) { - Set requested = new HashSet<>(); + Map latestByRecording = new HashMap<>(); for (AudioAnalysisEntity analysis : analysisRepository .findAllByInterviewIdOrderByAnalysisIdAsc(interview.getInterviewId())) { if (analysis.getStatus() == AudioAnalysisStatus.PENDING) { @@ -355,15 +488,19 @@ private Set settleEarlierRequests(InterviewEntity interview, Mapof())) { + latestByRecording.put(recordingId, analysis.getStatus()); } } - return requested; + return latestByRecording.entrySet().stream() + .filter(latest -> latest.getValue() != AudioAnalysisStatus.FAILED) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); } /** - * 접수를 확인하지 못한 요청을 같은 요청 id로 다시 보낸다. + * 접수를 확인하지 못한 요청을 같은 요청 id로 다시 보낸다. 사용자가 다시 요청해 아직 보내지 않은 요청도 여기서 + * 처음 나간다. * *

    포기 시간이 지나기 전에는 닫지 않는다. 202를 받지 못한 요청도 분석 서버는 받아 두었을 수 있어서, * 일찍 닫고 새 요청 id로 보내면 같은 답변이 두 번 분석된다. 같은 요청 id로 다시 보내는 것은 안전하다. @@ -390,7 +527,7 @@ private void confirmAcceptance(InterviewEntity interview, AudioAnalysisEntity an markFailed(analysis, NOT_ACKNOWLEDGED); return; } - log.info("접수를 확인하지 못한 음성 분석 요청을 같은 요청 id로 다시 보냅니다. interviewId={}, requestId={}", + log.info("접수를 확인하지 못한 음성 분석 요청을 그 요청 id로 보냅니다. interviewId={}, requestId={}", interview.getInterviewId(), analysis.getRequestId()); send(interview, analysis, chunk, callbackUrl, true); } @@ -451,15 +588,24 @@ private void markFailed(AudioAnalysisEntity analysis, String reason) { /** 새 요청 id로 접수 기록을 먼저 남기고 보낸다. 보낸 뒤에 남기면 응답을 못 받은 요청을 되짚을 수 없다. */ private void sendAsNewRequest(InterviewEntity interview, List chunk, String callbackUrl) { - AudioAnalysisEntity analysis = analysisRepository.save(AudioAnalysisEntity.builder() + AudioAnalysisEntity analysis = analysisRepository.save(newRequest(interview, recordingIds(chunk))); + send(interview, analysis, chunk, callbackUrl, true); + } + + /** 새 요청 id로 만든, 아직 보내지 않은 요청. */ + private static AudioAnalysisEntity newRequest(InterviewEntity interview, List recordingIds) { + return AudioAnalysisEntity.builder() .interviewId(interview.getInterviewId()) .userId(interview.getUserId()) .sessionId(interview.getSessionId()) .requestId("audio-" + UUID.randomUUID()) .status(AudioAnalysisStatus.PENDING) - .recordingIds(chunk.stream().map(loaded -> loaded.recording().getRecordingId()).toList()) - .build()); - send(interview, analysis, chunk, callbackUrl, true); + .recordingIds(recordingIds) + .build(); + } + + private static List recordingIds(List chunk) { + return chunk.stream().map(loaded -> loaded.recording().getRecordingId()).toList(); } /** @@ -497,14 +643,7 @@ private void send(InterviewEntity interview, AudioAnalysisEntity analysis, List< if (status.value() == HttpStatus.CONFLICT.value() && mayUseNewRequestId) { log.warn("같은 요청 id로 다른 내용을 보낸 것으로 거절당해 새 요청 id로 다시 보냅니다. requestId={}", analysis.getRequestId(), e); - AudioAnalysisEntity retry = analysisRepository.save(AudioAnalysisEntity.builder() - .interviewId(interview.getInterviewId()) - .userId(interview.getUserId()) - .sessionId(interview.getSessionId()) - .requestId("audio-" + UUID.randomUUID()) - .status(AudioAnalysisStatus.PENDING) - .recordingIds(chunk.stream().map(loaded -> loaded.recording().getRecordingId()).toList()) - .build()); + AudioAnalysisEntity retry = analysisRepository.save(newRequest(interview, recordingIds(chunk))); send(interview, retry, chunk, callbackUrl, false); return; } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackDispatchEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackDispatchEntity.java index 6fdb85d4..20dd7a4b 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackDispatchEntity.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackDispatchEntity.java @@ -54,6 +54,11 @@ public class FeedbackDispatchEntity { @Column(columnDefinition = "TEXT") private String lastError; + // 사용자가 다시 요청한 횟수. 음성 분석 행만 쓴다. 조건부 갱신으로만 바꾼다. + @Column(nullable = false) + @Builder.Default + private Integer userRetryCount = 0; + @CreationTimestamp @Column(nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackDispatchRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackDispatchRepository.java index 660bad7e..cbfcf233 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackDispatchRepository.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackDispatchRepository.java @@ -70,6 +70,27 @@ public interface FeedbackDispatchRepository extends JpaRepository한도와 "지금 요청 중이 아닌지"를 같은 문장에서 확인한다. 연달아 누른 요청이 둘 다 받아들여지거나 한도를 + * 넘지 않는다. + * + *

    {@link #reopen}과 달리 시도 횟수를 0으로 되돌리고 FAILED도 되돌린다. 자동 재시도를 다 쓰고 닫힌 건이라도 + * 사람이 다시 요청했으면 처음처럼 시도한다. 마지막 활동 시각은 그대로 둔다 — 대개 유예 시간이 지나 있어 다음 + * 스윕이 곧바로 집는다. + */ + @Transactional + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update FeedbackDispatchEntity d set d.status = repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackDispatchStatus.WAITING, " + + "d.claimedAt = null, d.nextAttemptAt = null, d.attemptCount = 0, d.lastError = null, " + + "d.userRetryCount = d.userRetryCount + 1 " + + "where d.interviewId = :interviewId and d.kind = :kind " + + "and d.status in (repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackDispatchStatus.DONE, " + + "repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackDispatchStatus.FAILED) " + + "and d.userRetryCount < :maxRetries") + int reopenForUserRetry(Long interviewId, FeedbackDispatchKind kind, int maxRetries); + /** * 차지한 채 오래 끝나지 않은 건을 WAITING으로 되돌린다. * diff --git a/src/main/resources/db/migration/V33__add_audio_analysis_user_retry.sql b/src/main/resources/db/migration/V33__add_audio_analysis_user_retry.sql new file mode 100644 index 00000000..ef55a229 --- /dev/null +++ b/src/main/resources/db/migration/V33__add_audio_analysis_user_retry.sql @@ -0,0 +1,8 @@ +-- 사용자가 음성 분석을 다시 요청할 수 있게 한다(D3). 면접당 정해진 횟수까지만 받는다. +-- +-- 횟수는 음성 분석 대기 행(kind = AUDIO_ANALYSIS)에 둔다. 한도 확인, 횟수 증가, 대기 행을 다시 기다리는 자리로 +-- 돌리는 일을 한 문장의 조건부 갱신으로 해야 연달아 누른 요청이 둘 다 받아들여지거나 한도를 넘지 않는다. +ALTER TABLE feedback_dispatch ADD COLUMN user_retry_count INTEGER NOT NULL DEFAULT 0; + +COMMENT ON COLUMN feedback_dispatch.user_retry_count IS + '사용자가 음성 분석을 다시 요청한 횟수. AUDIO_ANALYSIS 행만 쓴다. 면접당 2회까지 받는다.'; diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/controller/AudioAnalysisControllerTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/controller/AudioAnalysisControllerTest.java index c4da6d71..46ec4fd3 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/analysis/controller/AudioAnalysisControllerTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/controller/AudioAnalysisControllerTest.java @@ -62,7 +62,7 @@ void tearDown() { void 면접의_음성_분석을_답변별로_내려준다() throws Exception { when(audioAnalysisService.get(7L, 42L)).thenReturn(new AudioAnalysisResponse(Status.PARTIAL, List.of( new AudioAnalysisResponse.Answer("1203", "2207", "301", "audio-1", Status.PARTIAL, 120000L, - Map.of("status", "ready"), Map.of("status", "partial"), null)))); + Map.of("status", "ready"), Map.of("status", "partial"), null)), true)); mockMvc.perform(get("/api/interviews/42/audio-analysis")) .andExpect(status().isOk()) @@ -71,7 +71,18 @@ void tearDown() { .andExpect(jsonPath("$.data.answers[0].recordingId").value("301")) .andExpect(jsonPath("$.data.answers[0].requestId").value("audio-1")) .andExpect(jsonPath("$.data.answers[0].status").value("PARTIAL")) - .andExpect(jsonPath("$.data.answers[0].timing.status").value("ready")); + .andExpect(jsonPath("$.data.answers[0].timing.status").value("ready")) + .andExpect(jsonPath("$.data.canRetry").value(true)); + } + + @Test + void 음성_분석을_다시_요청하면_202로_받는다() throws Exception { + when(audioAnalysisService.retry(7L, 42L)).thenReturn(new AudioAnalysisResponse(Status.PENDING, List.of(), false)); + + mockMvc.perform(post("/api/interviews/42/audio-analysis/retry")) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.canRetry").value(false)); } @Test diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/AudioAnalysisServiceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/AudioAnalysisServiceTest.java index 99c54add..f9ed2bd1 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/AudioAnalysisServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/AudioAnalysisServiceTest.java @@ -760,6 +760,171 @@ void setUp() { new AudioAnalysisResponse.Error(null, "timeout", null)); } + // ---- 다시 요청 ---- + + /** 정상 결과도 말더듬 검출이 없어 PARTIAL이다. 상태만 보고 열면 모든 면접이 다시 요청 대상이 된다. */ + @Test + void 다시_보낼_답변은_결과가_없거나_분석_서버가_다시_하면_된다고_알린_실패가_있는_답변뿐이다() { + assertThat(AudioAnalysisService.worthRetrying(answer(Status.FAILED, null, null, null))).isTrue(); + assertThat(AudioAnalysisService.worthRetrying(answer(Status.UNAVAILABLE, null, null, null))).isTrue(); + assertThat(AudioAnalysisService.worthRetrying(answer(Status.PENDING, null, null, null))).isFalse(); + assertThat(AudioAnalysisService.worthRetrying(answer(Status.PARTIAL, TIMING, FLUENCY, null))).isFalse(); + // 전사할 말이 없다. 같은 음성이면 같은 결과다. + assertThat(AudioAnalysisService.worthRetrying(answer(Status.PARTIAL, TIMING, + failedLine("TRANSCRIPT_UNAVAILABLE_OR_EMPTY", false), null))).isFalse(); + assertThat(AudioAnalysisService.worthRetrying(answer(Status.PARTIAL, TIMING, + failedLine("EXTERNAL_SERVICE_UNAVAILABLE", true), null))).isTrue(); + // 원본을 받지 못해 두 라인이 막혔다. 라인 사유는 retryable=false여도 파일 사유가 retryable=true다. + assertThat(AudioAnalysisService.worthRetrying(answer(Status.UNAVAILABLE, + failedLine("REQUIRED_INPUT_FAILED", false), failedLine("REQUIRED_INPUT_FAILED", false), + new AudioAnalysisResponse.Error("SOURCE_DOWNLOAD_FAILED", "x", true)))).isTrue(); + } + + @Test + void 정상_결과만_있으면_다시_요청할_수_없고_다시_보낼_답변이_있으면_할_수_있다() { + answerRecordings(loaded(301L, 101L, 201L, null), loaded(302L, 102L, 202L, null)); + AudioAnalysisEntity done = analysis("audio-1", AudioAnalysisStatus.PARTIAL, List.of(301L, 302L), LocalDateTime.now()); + analyses.add(done); + dispatch(FeedbackDispatchStatus.DONE, 0); + + results(resultRow(done, 1L, 301L, "PARTIAL", null), resultRow(done, 2L, 302L, "PARTIAL", null)); + assertThat(service.get(7L, INTERVIEW_ID).canRetry()).isFalse(); + + results(resultRow(done, 1L, 301L, "PARTIAL", null), retryableFailureRow(done, 2L, 302L)); + assertThat(service.get(7L, INTERVIEW_ID).canRetry()).isTrue(); + } + + @Test + void 다시_요청하면_다시_보낼_답변만_새_요청_자리로_만들고_대기_행을_돌린다() { + answerRecordings(loaded(301L, 101L, 201L, null), loaded(302L, 102L, 202L, null), loaded(303L, 103L, 203L, null)); + AudioAnalysisEntity done = analysis("audio-1", AudioAnalysisStatus.PARTIAL, List.of(301L, 302L, 303L), LocalDateTime.now()); + analyses.add(done); + // 301은 정상, 302는 일시 장애로 fluency가 실패했고, 303은 결과가 빠졌다. + results(resultRow(done, 1L, 301L, "PARTIAL", null), retryableFailureRow(done, 2L, 302L)); + dispatch(FeedbackDispatchStatus.DONE, 0); + when(dispatchRepository.reopenForUserRetry(INTERVIEW_ID, FeedbackDispatchKind.AUDIO_ANALYSIS, 2)).thenReturn(1); + + AudioAnalysisResponse response = service.retry(7L, INTERVIEW_ID); + + assertThat(analyses).hasSize(2); + AudioAnalysisEntity again = analyses.getLast(); + assertThat(again.getRecordingIds()).containsExactly(302L, 303L); + assertThat(again.getStatus()).isEqualTo(AudioAnalysisStatus.PENDING); + assertThat(again.getJobId()).isNull(); + assertThat(again.getRequestId()).startsWith("audio-").isNotEqualTo("audio-1"); + // 보내는 것은 대기 스윕이 한다. + verify(aiServerClient, never()).requestAudioAnalysis(any()); + assertThat(response.status()).isEqualTo(Status.PENDING); + assertThat(response.answers()).extracting(Answer::status, Answer::requestId).containsExactly( + tuple(Status.PARTIAL, "audio-1"), tuple(Status.PENDING, again.getRequestId()), + tuple(Status.PENDING, again.getRequestId())); + assertThat(response.canRetry()).isFalse(); + } + + @Test + void 다시_요청을_받지_않으면_이유를_409로_알리고_아무것도_만들지_않는다() { + answerRecordings(loaded(301L, 101L, 201L, null)); + AudioAnalysisEntity done = analysis("audio-1", AudioAnalysisStatus.PARTIAL, List.of(301L), LocalDateTime.now()); + analyses.add(done); + results(retryableFailureRow(done, 1L, 301L)); + + assertRetryBlocked("아직 분석을 시작하지 않았습니다."); + dispatch(FeedbackDispatchStatus.WAITING, 0); + assertRetryBlocked("이미 분석 중입니다."); + dispatch(FeedbackDispatchStatus.DONE, 2); + assertRetryBlocked("면접당 2번까지"); + // 연달아 누른 다른 요청이 먼저 받아들여졌다. + dispatch(FeedbackDispatchStatus.DONE, 1); + assertRetryBlocked("이미 분석 중입니다."); + results(resultRow(done, 1L, 301L, "PARTIAL", null)); + assertRetryBlocked("다시 분석할 답변이 없습니다."); + + assertThat(analyses).hasSize(1); + } + + /** 다시 요청한 요청이 받아들여지지 않고 닫혔다. 앞선 결과가 있다고 "이미 맡긴 녹음"으로 보면 다시 요청이 사라진다. */ + @Test + void 다시_요청한_요청이_닫히면_앞선_결과가_있어도_그_녹음을_다시_보낸다() { + answerRecordings(loaded(301L, 101L, 201L, null), loaded(302L, 102L, 202L, null)); + analyses.add(analysis("audio-1", AudioAnalysisStatus.PARTIAL, List.of(301L, 302L), LocalDateTime.now())); + analyses.add(analysis("audio-2", null, AudioAnalysisStatus.FAILED, List.of(302L), LocalDateTime.now())); + + service.requestForFinishedInterview(INTERVIEW_ID); + + List sent = sentRequests(); + assertThat(sent).hasSize(1); + assertThat(sent.getFirst().getRequestId()).isNotIn("audio-1", "audio-2"); + assertThat(sent.getFirst().getRecordings()).extracting(AudioAnalysisRequest.Recording::getRecordingId) + .containsExactly("302"); + } + + @Test + void 다시_요청해_만든_요청은_그_요청_id로_처음_보낸다() { + answerRecordings(loaded(301L, 101L, 201L, null)); + analyses.add(analysis("audio-1", AudioAnalysisStatus.UNAVAILABLE, List.of(301L), LocalDateTime.now().minusDays(1))); + AudioAnalysisEntity again = analysis("audio-2", null, AudioAnalysisStatus.PENDING, List.of(301L), LocalDateTime.now()); + analyses.add(again); + + service.requestForFinishedInterview(INTERVIEW_ID); + + assertThat(sentRequests()).extracting(AudioAnalysisRequest::getRequestId).containsExactly("audio-2"); + assertThat(again.getJobId()).isEqualTo("job-audio-2"); + assertThat(analyses).hasSize(2); + } + + /** 다시 분석하는 동안 앞선 결과를 보여 주면 전체가 PENDING이 아니게 돼 웹이 조회를 멈추고 새 결과를 놓친다. */ + @Test + void 다시_분석하는_동안은_PENDING이고_다시_요청이_닫히면_앞선_결과로_돌아간다() { + answerRecordings(loaded(301L, 101L, 201L, null)); + AudioAnalysisEntity first = analysis("audio-1", AudioAnalysisStatus.PARTIAL, List.of(301L), LocalDateTime.now()); + analyses.add(first); + AudioAnalysisEntity again = analysis("audio-2", AudioAnalysisStatus.PENDING, List.of(301L), LocalDateTime.now()); + analyses.add(again); + results(retryableFailureRow(first, 1L, 301L)); + dispatch(FeedbackDispatchStatus.DONE, 1); + + AudioAnalysisResponse during = service.get(7L, INTERVIEW_ID); + assertThat(during.status()).isEqualTo(Status.PENDING); + assertThat(during.answers().getFirst().requestId()).isEqualTo("audio-2"); + + again.setStatus(AudioAnalysisStatus.FAILED); + AudioAnalysisResponse closed = service.get(7L, INTERVIEW_ID); + assertThat(closed.answers()).extracting(Answer::status, Answer::requestId) + .containsExactly(tuple(Status.PARTIAL, "audio-1")); + assertThat(closed.canRetry()).isTrue(); + } + + private void assertRetryBlocked(String reason) { + assertThatThrownBy(() -> service.retry(7L, INTERVIEW_ID)) + .isInstanceOfSatisfying(BusinessException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)) + .hasMessageContaining(reason); + } + + private static Answer answer(Status status, Map timing, Map fluency, + AudioAnalysisResponse.Error error) { + return new Answer("101", "201", "301", "audio-1", status, null, timing, fluency, error); + } + + /** 분석 서버가 실패로 알린 라인. data 없이 {code, message, retryable}만 온다. */ + private static Map failedLine(String code, boolean retryable) { + Map line = new LinkedHashMap<>(); + line.put("status", "unavailable"); + line.put("data", null); + line.put("error", Map.of("code", code, "message", "음성 분석의 일부 작업을 완료하지 못했습니다.", "retryable", retryable)); + return line; + } + + /** 일시 장애로 fluency만 실패한 결과 행. */ + private static AudioAnalysisResultEntity retryableFailureRow(AudioAnalysisEntity analysis, long resultId, + long recordingId) { + return AudioAnalysisResultEntity.builder() + .resultId(resultId).analysisId(analysis.getAnalysisId()).recordingId(recordingId) + .questionId(101L).answerId(201L).status("PARTIAL").durationMs(120000L) + .timing(TIMING).fluency(failedLine("EXTERNAL_SERVICE_UNAVAILABLE", true)) + .build(); + } + private static Status overall(Status... statuses) { return AudioAnalysisService.overallStatus(List.of(statuses).stream() .map(status -> new Answer("1", "1", "1", null, status, null, null, null, null)) @@ -768,9 +933,14 @@ private static Status overall(Status... statuses) { /** 이 면접의 음성 분석 대기 행. 없으면 면접 기록이 아직 오지 않은 것이다. */ private void dispatch(FeedbackDispatchStatus status) { + dispatch(status, 0); + } + + private void dispatch(FeedbackDispatchStatus status, int userRetryCount) { when(dispatchRepository.findByInterviewIdAndKind(INTERVIEW_ID, FeedbackDispatchKind.AUDIO_ANALYSIS)) .thenReturn(Optional.of(FeedbackDispatchEntity.builder() - .interviewId(INTERVIEW_ID).kind(FeedbackDispatchKind.AUDIO_ANALYSIS).status(status).build())); + .interviewId(INTERVIEW_ID).kind(FeedbackDispatchKind.AUDIO_ANALYSIS).status(status) + .userRetryCount(userRetryCount).build())); } private void results(AudioAnalysisResultEntity... rows) { diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackDispatchRepositoryTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackDispatchRepositoryTest.java index 37c31085..93b91d93 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackDispatchRepositoryTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackDispatchRepositoryTest.java @@ -147,6 +147,38 @@ class FeedbackDispatchRepositoryTest { .isEqualTo(FeedbackDispatchStatus.FAILED); } + /** 사용자의 음성 분석 다시 요청. 요청 중에 받거나 한도를 넘으면 같은 면접이 겹쳐 분석된다. */ + @Test + void 다시_요청은_끝난_건만_한도까지_받고_시도_횟수를_처음으로_되돌린다() { + FeedbackDispatchEntity saved = repository.save(waiting( + ThreadLocalRandom.current().nextLong(1_000_000_000L, Long.MAX_VALUE), FeedbackDispatchKind.AUDIO_ANALYSIS, NOW)); + Long dispatchId = saved.getDispatchId(); + Long interviewId = saved.getInterviewId(); + repository.claim(dispatchId, NOW); + repository.markDone(dispatchId, NOW); + + assertThat(repository.reopenForUserRetry(interviewId, FeedbackDispatchKind.AUDIO_ANALYSIS, 2)).isEqualTo(1); + FeedbackDispatchEntity reopened = repository.findById(dispatchId).orElseThrow(); + assertThat(reopened.getStatus()).isEqualTo(FeedbackDispatchStatus.WAITING); + assertThat(reopened.getAttemptCount()).isZero(); + assertThat(reopened.getClaimedAt()).isNull(); + assertThat(reopened.getUserRetryCount()).isEqualTo(1); + // 이미 기다리는 중이라 연달아 누른 요청은 받지 않는다. + assertThat(repository.reopenForUserRetry(interviewId, FeedbackDispatchKind.AUDIO_ANALYSIS, 2)).isZero(); + + // 자동 재시도를 다 쓰고 닫힌 건도 받는다. + repository.claim(dispatchId, NOW); + repository.markFailed(dispatchId, NOW, "시도 한도"); + assertThat(repository.reopenForUserRetry(interviewId, FeedbackDispatchKind.AUDIO_ANALYSIS, 2)).isEqualTo(1); + + repository.claim(dispatchId, NOW); + repository.markDone(dispatchId, NOW); + assertThat(repository.reopenForUserRetry(interviewId, FeedbackDispatchKind.AUDIO_ANALYSIS, 2)).isZero(); + FeedbackDispatchEntity exhausted = repository.findById(dispatchId).orElseThrow(); + assertThat(exhausted.getStatus()).isEqualTo(FeedbackDispatchStatus.DONE); + assertThat(exhausted.getUserRetryCount()).isEqualTo(2); + } + /** 한 면접에 채점과 음성 분석이 따로 한 행씩 있다. 한쪽을 건드려도 다른 쪽은 그대로다. */ @Test void 같은_면접의_채점과_음성_분석은_따로_기다린다() {