From d0086909dd4264b0d099248df5979aed9a3c613d Mon Sep 17 00:00:00 2001 From: kimwuju Date: Tue, 20 Oct 2026 21:30:03 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(interview):=20=EC=83=88=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EA=B8=B0=EB=A1=9D=EC=9D=80=20COMPLETED/ABANDONED?= =?UTF-8?q?=EB=A7=8C=20=EB=B0=9B=EA=B3=A0=20=EC=A0=80=EC=9E=A5=EB=90=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A1=9C=20=EC=B1=84=EC=A0=90=EC=9D=84=20?= =?UTF-8?q?=EC=A4=80=EB=B9=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - saveInterview의 null/IN_PROGRESS → COMPLETED 보정을 없앴다. 새 기록의 잘못된 상태는 claim 전에 422로 거절해 면접은 진행 중으로 남는다. - 이미 종료된 면접은 다시 온 요청의 상태와 상관없이 첫 기록·질문 PK·최종 상태를 그대로 둔다. - handleResult는 저장 커밋 뒤 DB 상태를 다시 읽어 COMPLETED만 채점·분석을 준비한다. 중단 기록은 보존하고 정상 응답한다. - 공통 정책 객체 InterviewFeedbackPolicy를 두고 FeedbackService도 같은 판단을 쓴다. - 모르는 상태 문자열은 400 대신 null로 받아 422로 거절한다. Co-Authored-By: Claude Opus 5.5 --- .../feedback/service/FeedbackService.java | 10 +-- .../dto/request/SaveInterviewRequest.java | 4 + .../service/ChatInterviewResultService.java | 13 ++- .../service/InterviewFeedbackPolicy.java | 62 ++++++++++++++ .../interview/service/InterviewService.java | 34 ++++++-- ...veInterviewRequestDeserializationTest.java | 13 +++ .../ChatInterviewResultServiceTest.java | 27 +++++++ .../InterviewResultIdempotencyTest.java | 81 ++++++++++++++++++- .../InterviewServiceSaveResultTest.java | 49 ++++++++--- 9 files changed, 267 insertions(+), 26 deletions(-) create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewFeedbackPolicy.java diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java index 03fa242c..91f8bf46 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java @@ -29,6 +29,7 @@ import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; import repit.repit_api_server.domain.userdata.interview.repository.InterviewPersonaRepository; import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; +import repit.repit_api_server.domain.userdata.interview.service.InterviewFeedbackPolicy; import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; @@ -146,18 +147,15 @@ public void requestFeedbackForFinishedInterview(Long interviewId) { /** * 서버가 정상 완료로 확정한 면접만 채점한다. 중단(ABANDONED)과 진행 중(IN_PROGRESS)은 채점하지 않는다. - * 소켓이 끊긴 것만으로는 완료나 중단으로 보지 않는다 — 판단은 저장된 상태로만 한다. + * 판단은 영상 분석과 같은 {@link InterviewFeedbackPolicy}가 한다. */ private static boolean isFeedbackEligible(InterviewEntity interview) { - return interview.getStatus() == Status.COMPLETED; + return InterviewFeedbackPolicy.isEligible(interview); } /** 수동 요청이 채점 대상이 아닐 때 돌려줄 오류. 기존 오류 envelope 그대로 409다. */ private static BusinessException notEligible(InterviewEntity interview) { - if (interview.getStatus() == Status.ABANDONED) { - return BusinessException.conflict("중단된 면접은 피드백을 생성하지 않습니다."); - } - return BusinessException.conflict("면접이 아직 끝나지 않았습니다. 완료된 면접만 피드백을 요청할 수 있습니다."); + return InterviewFeedbackPolicy.notEligible(interview); } private InterviewEntity findInterview(Long interviewId) { diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequest.java index 2faadb50..322c4404 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequest.java @@ -1,5 +1,6 @@ package repit.repit_api_server.domain.userdata.interview.dto.request; +import com.fasterxml.jackson.annotation.JsonFormat; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -28,6 +29,9 @@ public class SaveInterviewRequest { private String sessionId; private Long interviewId; private Long userId; + // 채팅 서버가 확정한 종료 상태. 새 기록은 COMPLETED 또는 ABANDONED여야 한다. 모르는 값은 본문 해석 오류(400)로 + // 끊지 않고 비워 받아, 저장할 때 상태 검사가 422로 거절한다(이미 저장한 면접의 재전송이면 그대로 무시된다). + @JsonFormat(with = JsonFormat.Feature.READ_UNKNOWN_ENUM_VALUES_AS_NULL) private Status status; private LocalDateTime interviewCreatedAt; private List qnaRequests; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultService.java index d412ca3d..f7eaad2a 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultService.java @@ -6,9 +6,10 @@ import org.springframework.stereotype.Service; import repit.repit_api_server.domain.userdata.feedback.service.FeedbackDispatchService; import repit.repit_api_server.domain.userdata.interview.dto.request.SaveInterviewRequest; +import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; /** - * 채팅 서버가 면접을 마치고 넘겨주는 기록을 받는 자리. 기록을 저장하고, 이어서 채점을 준비한다. + * 채팅 서버가 면접을 마치고 넘겨주는 기록을 받는 자리. 기록을 저장하고, 정상 완료된 면접이면 이어서 채점을 준비한다. * *

채점 요청을 {@code InterviewService.saveInterview} 안에 두지 않는 이유는 두 가지다. 저장은 * 트랜잭션 안에서 이뤄지는데 채점 접수는 분석 서버를 부르는 느린 HTTP 호출이라, 같이 묶으면 그 @@ -34,6 +35,10 @@ public class ChatInterviewResultService { *

저장 실패는 그대로 올려 보낸다. 채팅 서버가 실패를 알아야 기록을 잃은 것을 알 수 있다. * 반대로 채점·음성 분석 준비 실패는 여기서 삼킨다 — 면접 기록은 이미 저장됐고, 이 응답이 실패로 가면 * 채팅 서버의 면접 완료 처리까지 끊긴다. 채점은 웹에서 다시 요청할 수 있다. + * + *

채점을 준비할지는 받은 요청의 상태가 아니라 커밋 뒤 DB에서 다시 읽은 상태로 정한다. 이미 COMPLETED로 저장된 면접에 + * ABANDONED가 다시 와도, 그 반대여도 처음 확정된 상태를 따른다. 중단 면접은 기록만 남기고 정상 응답한다 — 실패로 답하면 + * 채팅 서버의 전달 대기열이 같은 기록을 계속 다시 보낸다. */ public void handleResult(SaveInterviewRequest request) { interviewService.saveInterview(request); @@ -41,6 +46,12 @@ public void handleResult(SaveInterviewRequest request) { // 이미 저장한 기록을 다시 받았어도 부른다. 처음 저장이 커밋된 직후 서버가 내려갔거나 여기서 실패했으면 채점·음성 // 분석 대기 행이 없고, 스윕은 있는 대기 행만 훑어 아무도 채우지 않는다. 종류마다 한 번만 요청하므로 다시 불러도 // 중복 요청은 없다. + Status stored = interviewService.findStoredStatus(request.getInterviewId()); + if (stored != Status.COMPLETED) { + log.info("정상 완료된 면접이 아니라 채점·분석을 준비하지 않습니다. interviewId={}, 저장된 상태={}, 받은 상태={}", + request.getInterviewId(), stored, request.getStatus()); + return; + } try { feedbackDispatchService.onTranscriptSaved(request.getInterviewId()); diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewFeedbackPolicy.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewFeedbackPolicy.java new file mode 100644 index 00000000..1d22b547 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewFeedbackPolicy.java @@ -0,0 +1,62 @@ +package repit.repit_api_server.domain.userdata.interview.service; + +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; +import repit.repit_api_server.global.exception.BusinessException; + +/** + * 면접 기록으로 채점·기업 코멘트·음성/영상 분석을 실행해도 되는지 정하는 한 곳. + * + *

판단은 이 서버에 저장된 면접 상태로만 한다. 서버가 정상 완료(COMPLETED)로 확정한 면접만 실행한다. 중단(ABANDONED)은 + * 기록만 남기고 실행하지 않으며, 진행 중(IN_PROGRESS)은 끝날 때까지 미룬다. 소켓 끊김, 완료 화면 진입, 답변 개수, 녹음의 + * 종료 이유로는 판단하지 않는다 — 브라우저가 보낸 신호라 서버가 확정한 상태가 아니다. + * + *

컨트롤러에만 두지 않는다. 스윕·재전송·작업 조회 복구·늦은 콜백처럼 사용자 요청 없이 도는 길도 같은 판단을 거친다. + */ +public final class InterviewFeedbackPolicy { + + /** 사용자 요청 없이 도는 길(스윕·업로드 뒤·콜백)이 따를 판단. */ + public enum Decision { + /** 정상 완료. 실행한다. */ + RUN, + /** 중단됐거나 면접이 없다. 실행하지 않고, 대기 작업은 정책 사유로 닫는다. 장애가 아니므로 재시도하지 않는다. */ + SKIP, + /** 아직 끝나지 않았다. 지우거나 닫지 않고 완료를 기다린다. */ + HOLD + } + + private InterviewFeedbackPolicy() { + } + + public static boolean isEligible(InterviewEntity interview) { + return interview != null && interview.getStatus() == Status.COMPLETED; + } + + public static Decision decide(InterviewEntity interview) { + if (isEligible(interview)) { + return Decision.RUN; + } + if (interview == null || interview.getStatus() == Status.ABANDONED) { + return Decision.SKIP; + } + return Decision.HOLD; + } + + /** + * 사용자 요청(수동 생성·재분석·단건 조회)이 대상이 아니면 409로 막는다. 소유권을 확인한 뒤에 부른다 — 남의 면접이나 + * 없는 면접을 409로 답하면 인증·권한 오류가 정책 오류로 가려진다. + */ + public static void requireEligible(InterviewEntity interview) { + if (!isEligible(interview)) { + throw notEligible(interview); + } + } + + /** 대상이 아닌 면접에 돌려줄 오류. 기존 오류 envelope 그대로 409다. */ + public static BusinessException notEligible(InterviewEntity interview) { + if (interview != null && interview.getStatus() == Status.ABANDONED) { + return BusinessException.conflict("중단된 면접은 피드백을 생성하지 않습니다."); + } + return BusinessException.conflict("면접이 아직 끝나지 않았습니다. 완료된 면접만 피드백을 요청할 수 있습니다."); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java index bf4c524e..e7dad243 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java @@ -456,18 +456,32 @@ private ChatAnswerResponse toChatAnswer(InterviewEntity interview, QuestionEntit * 면접은 진행 중으로 남아, 다시 보낸 기록이 처음부터 저장된다. * *

채팅 서버는 저장이 끝났는데 응답을 받지 못하면 같은 기록을 다시 보낸다. 이미 저장한 면접이면 아무것도 바꾸지 - * 않는다. 지우고 다시 넣으면 질문 PK가 바뀌어 그 질문에 매달린 채점·녹음과 어긋난다. 내용이 달라도 처음 것을 둔다. + * 않는다. 지우고 다시 넣으면 질문 PK가 바뀌어 그 질문에 매달린 채점·녹음과 어긋난다. 내용이 달라도, 상태가 반대로 와도 + * 처음 것을 둔다. + * + *

새 기록의 상태는 COMPLETED 또는 ABANDONED만 받는다. 중단 기록도 질문·답변을 그대로 남긴다 — 채점·분석을 하지 않을 + * 뿐 기록은 보존한다. 무엇을 실행할지는 저장이 끝난 뒤 저장된 상태를 다시 읽어 정한다({@link ChatInterviewResultService}). */ @Transactional public void saveInterview(SaveInterviewRequest request) { InterviewEntity interview = interviewRepository.findById(request.getInterviewId()) .orElseThrow(() -> BusinessException.notFound("면접을 찾을 수 없습니다")); - // 기록을 넘겼다는 것 자체가 면접이 끝났다는 뜻이다. 상태가 비거나 진행 중으로 와도 진행 중으로 두지 않는다 — - // 진행 중으로 두면 다시 온 기록을 처음 것으로 보고 또 저장한다. - Status status = request.getStatus() == null || request.getStatus() == Status.IN_PROGRESS - ? Status.COMPLETED - : request.getStatus(); + // 이미 종료 기록을 저장한 면접이다. 다시 온 요청의 상태가 무엇이든 처음 저장한 기록·질문 PK·최종 상태를 그대로 둔다. + // 상태 검사보다 먼저 본다 — 상태를 빠뜨린 재전송을 422로 돌려보내면 채팅 서버가 이미 끝난 전달을 계속 다시 보낸다. + if (interview.getStatus() != Status.IN_PROGRESS) { + log.info("이미 기록을 저장한 면접이라 다시 저장하지 않습니다. interviewId={}, 저장된 상태={}, 받은 상태={}, 받은 문항 수={}", + interview.getInterviewId(), interview.getStatus(), request.getStatus(), + request.getQnaRequests() == null ? 0 : request.getQnaRequests().size()); + return; + } + + // 새 종료 기록은 채팅 서버가 확정한 상태를 밝혀야 한다. 비었거나 진행 중이면 COMPLETED로 고쳐 받지 않는다 — 그렇게 + // 받으면 중단된 면접까지 채점·영상 분석 대상이 된다. 차지(claim)하기 전에 거절해 면접은 진행 중으로 남는다. + Status status = request.getStatus(); + if (status != Status.COMPLETED && status != Status.ABANDONED) { + throw BusinessException.unprocessable("면접 종료 상태(status)는 COMPLETED 또는 ABANDONED여야 합니다."); + } if (interviewRepository.claimResultSave(interview.getInterviewId(), status) == 0) { log.info("이미 기록을 저장한 면접이라 다시 저장하지 않습니다. interviewId={}, 받은 상태={}, 받은 문항 수={}", interview.getInterviewId(), request.getStatus(), @@ -494,6 +508,14 @@ public void saveInterview(SaveInterviewRequest request) { saveAnswers(interview, qnAs, questionIdByChatId); } + /** + * 지금 DB에 저장된 면접 상태. 기록 저장이 커밋된 뒤 무엇을 실행할지 정할 때 쓴다 — 받은 요청의 상태가 아니라 처음 확정된 + * 상태를 따라야 반대 상태로 다시 온 기록이 채점을 열지 못한다. 면접이 없으면 null이다. + */ + public Status findStoredStatus(Long interviewId) { + return interviewRepository.findById(interviewId).map(InterviewEntity::getStatus).orElse(null); + } + /** * 질문을 저장하고 채팅 서버 번호 -> 우리 PK 매핑을 돌려준다. * diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequestDeserializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequestDeserializationTest.java index c29196f5..bb1f4d82 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequestDeserializationTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/dto/request/SaveInterviewRequestDeserializationTest.java @@ -98,4 +98,17 @@ class SaveInterviewRequestDeserializationTest { assertThat(request.getQnaRequests().get(1).getAnswer()).isNull(); }); } + + /** 모르는 상태 값은 본문 해석 오류(400)로 끊지 않고 비워 받는다. 저장할 때 상태 검사가 422로 거절한다. */ + @Test + void 모르는_종료_상태는_비워_받고_중단은_그대로_읽는다() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + assertThat(objectMapper.readValue(CHAT_REQUEST.replace("\"COMPLETED\"", "\"DONE\""), SaveInterviewRequest.class) + .getStatus()).isNull(); + assertThat(objectMapper.readValue(CHAT_REQUEST.replace("\"COMPLETED\"", "\"ABANDONED\""), SaveInterviewRequest.class) + .getStatus()).isEqualTo(Status.ABANDONED); + }); + } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultServiceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultServiceTest.java index 74ba334d..b9d8f238 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewResultServiceTest.java @@ -17,8 +17,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** * 채팅 서버가 면접을 마치고 기록을 넘기는 자리. 저장에 이어 채점 준비까지 여기서 시작한다. @@ -41,6 +43,7 @@ class ChatInterviewResultServiceTest { void setUp() { service = new ChatInterviewResultService(interviewService, feedbackDispatchService); request = new SaveInterviewRequest("sess-1", 3L, 7L, Status.COMPLETED, null, List.of()); + lenient().when(interviewService.findStoredStatus(3L)).thenReturn(Status.COMPLETED); } @Test @@ -64,6 +67,30 @@ void setUp() { verify(feedbackDispatchService, never()).onTranscriptSaved(3L); } + /** 중단 기록은 남기되 채점·분석은 열지 않는다. 정상 응답해야 채팅 서버의 전달 대기열이 정리된다. */ + @Test + void 중단으로_저장된_면접은_채점을_준비하지_않고_정상_응답한다() { + SaveInterviewRequest abandoned = new SaveInterviewRequest("sess-1", 3L, 7L, Status.ABANDONED, null, List.of()); + when(interviewService.findStoredStatus(3L)).thenReturn(Status.ABANDONED); + + assertThatCode(() -> service.handleResult(abandoned)).doesNotThrowAnyException(); + + verify(interviewService).saveInterview(abandoned); + verify(feedbackDispatchService, never()).onTranscriptSaved(3L); + } + + /** 요청이 아니라 처음 확정된 상태를 따른다. 완료 뒤 늦게 온 중단도, 중단 뒤 늦게 온 완료도 결과를 바꾸지 않는다. */ + @Test + void 받은_상태가_아니라_저장된_상태로_채점_준비를_정한다() { + SaveInterviewRequest lateAbandon = new SaveInterviewRequest("sess-1", 3L, 7L, Status.ABANDONED, null, List.of()); + service.handleResult(lateAbandon); + verify(feedbackDispatchService).onTranscriptSaved(3L); + + when(interviewService.findStoredStatus(3L)).thenReturn(Status.ABANDONED); + service.handleResult(request); + verify(feedbackDispatchService, org.mockito.Mockito.times(1)).onTranscriptSaved(3L); + } + @Test void 채점_준비가_실패해도_면접_완료_처리를_끊지_않는다() { doThrow(new IllegalStateException("db down")) diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewResultIdempotencyTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewResultIdempotencyTest.java index 6e314dd5..4e1faab6 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewResultIdempotencyTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewResultIdempotencyTest.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.support.TransactionTemplate; import repit.repit_api_server.domain.userdata.answer.entity.AnswerEntity; @@ -18,6 +19,7 @@ import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; import repit.repit_api_server.domain.userdata.question.entity.QuestionEntity; import repit.repit_api_server.domain.userdata.question.repository.QuestionRepository; +import repit.repit_api_server.global.exception.BusinessException; import java.time.LocalDateTime; import java.util.List; @@ -29,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -77,7 +80,11 @@ void cleanUp() { } private SaveInterviewRequest record(String answerContent) { - return new SaveInterviewRequest(null, interviewId, 9L, Status.COMPLETED, null, List.of( + return record(Status.COMPLETED, answerContent); + } + + private SaveInterviewRequest record(Status status, String answerContent) { + return new SaveInterviewRequest(null, interviewId, 9L, status, null, List.of( new SaveInterviewRequest.QnA( new SaveInterviewRequest.Question(1L, null, "tech_choice", "Redis를 왜 썼나요?", "선택 근거", "캐시 선택 근거", List.of()), @@ -117,6 +124,78 @@ private List questions() { verify(feedbackDispatchService, times(3)).onTranscriptSaved(interviewId); } + /** 상태를 COMPLETED로 고쳐 받지 않는다. 거절은 면접을 진행 중으로 둬, 같은 기록이 올바른 상태로 오면 처음부터 저장된다. */ + @Test + void 새_기록의_상태가_없거나_진행_중이면_422이고_면접은_그대로다() { + for (Status invalid : java.util.Arrays.asList(null, Status.IN_PROGRESS)) { + assertThatThrownBy(() -> chatInterviewResultService.handleResult(record(invalid, "답변"))) + .isInstanceOfSatisfying(BusinessException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT)); + } + + assertThat(interviewRepository.findById(interviewId).orElseThrow().getStatus()).isEqualTo(Status.IN_PROGRESS); + assertThat(questions()).isEmpty(); + verify(feedbackDispatchService, never()).onTranscriptSaved(interviewId); + + chatInterviewResultService.handleResult(record(Status.COMPLETED, "답변")); + assertThat(interviewRepository.findById(interviewId).orElseThrow().getStatus()).isEqualTo(Status.COMPLETED); + assertThat(questions()).hasSize(2); + } + + /** 중단 기록도 질문·답변을 남긴다. 채점 준비만 하지 않고, 뒤늦은 완료 요청이 와도 중단을 지킨다. */ + @Test + void 중단_기록은_보존하고_채점을_준비하지_않으며_뒤늦은_완료가_바꾸지_못한다() { + chatInterviewResultService.handleResult(record(Status.ABANDONED, "여기까지 답했습니다.")); + List first = questions().stream().map(QuestionEntity::getQuestionId).toList(); + + chatInterviewResultService.handleResult(record(Status.COMPLETED, "다른 답변")); + chatInterviewResultService.handleResult(record(null, "상태 없는 재전송")); + + assertThat(interviewRepository.findById(interviewId).orElseThrow().getStatus()).isEqualTo(Status.ABANDONED); + assertThat(questions()).extracting(QuestionEntity::getQuestionId).containsExactlyElementsOf(first); + assertThat(answerRepository.findAllByInterviewIdOrderByAnswerIdAsc(interviewId)) + .extracting(AnswerEntity::getContent).contains("여기까지 답했습니다."); + verify(feedbackDispatchService, never()).onTranscriptSaved(interviewId); + } + + @Test + void 완료_뒤_늦게_온_중단은_완료와_질문_PK를_바꾸지_않는다() { + chatInterviewResultService.handleResult(record(Status.COMPLETED, "답변")); + List first = questions().stream().map(QuestionEntity::getQuestionId).toList(); + + chatInterviewResultService.handleResult(record(Status.ABANDONED, "다른 답변")); + + assertThat(interviewRepository.findById(interviewId).orElseThrow().getStatus()).isEqualTo(Status.COMPLETED); + assertThat(questions()).extracting(QuestionEntity::getQuestionId).containsExactlyElementsOf(first); + // 늦은 중단도 저장된 COMPLETED를 따른다. 빠진 대기 행을 채우는 호출이라 다시 불러도 중복 요청은 없다. + verify(feedbackDispatchService, times(2)).onTranscriptSaved(interviewId); + } + + /** 완료와 중단이 동시에 와도 처음 확정된 상태 하나만 남고, 채점 준비는 그 상태를 따른다. */ + @Test + void 완료와_중단이_동시에_와도_먼저_확정한_상태만_남는다() { + CountDownLatch start = new CountDownLatch(1); + List> requests = List.of(Status.COMPLETED, Status.ABANDONED, Status.COMPLETED, + Status.ABANDONED).stream() + .map(status -> CompletableFuture.runAsync(() -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + chatInterviewResultService.handleResult(record(status, "답변 " + status)); + })) + .toList(); + + start.countDown(); + CompletableFuture.allOf(requests.toArray(CompletableFuture[]::new)).join(); + + Status stored = interviewRepository.findById(interviewId).orElseThrow().getStatus(); + assertThat(stored).isIn(Status.COMPLETED, Status.ABANDONED); + assertThat(questions()).hasSize(2); + verify(feedbackDispatchService, times(stored == Status.COMPLETED ? 4 : 0)).onTranscriptSaved(interviewId); + } + @Test void 동시에_받아도_한_번만_저장한다() { CountDownLatch start = new CountDownLatch(1); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java index f92fbc59..437ac868 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java @@ -282,15 +282,47 @@ private List savedAnswers() { assertThat(saved.getValue().getSessionId()).isEqualTo("sess-1"); } + /** 비거나 진행 중인 상태를 COMPLETED로 고쳐 받으면 중단된 면접까지 채점·영상 분석 대상이 된다. */ @Test - void 상태가_비어_오면_끝난_것으로_본다() { - service.saveInterview(new SaveInterviewRequest("sess-1", 3L, 7L, null, - LocalDateTime.parse("2026-08-18T01:00:00"), List.of())); + void 새_기록의_상태가_비었거나_진행_중이면_차지하지_않고_422로_거절한다() { + for (Status status : java.util.Arrays.asList(null, Status.IN_PROGRESS)) { + assertThatThrownBy(() -> service.saveInterview(new SaveInterviewRequest("sess-1", 3L, 7L, status, + LocalDateTime.parse("2026-08-18T01:00:00"), List.of()))) + .isInstanceOfSatisfying(BusinessException.class, + e -> assertThat(e.getStatus()).isEqualTo(org.springframework.http.HttpStatus.UNPROCESSABLE_CONTENT)); + } + + // 거절이 면접 상태를 바꾸지 않는다. 같은 기록이 올바른 상태로 다시 오면 처음부터 저장된다. + verify(interviewRepository, never()).claimResultSave(anyLong(), any()); + verify(interviewRepository, never()).save(any()); + verify(questionRepository, never()).deleteAllByInterviewId(anyLong()); + } - // 기록을 넘겼다는 것 자체가 면접이 끝났다는 뜻이다. + @Test + void 중단_기록도_질문과_답변을_그대로_남긴다() { + service.saveInterview(new SaveInterviewRequest("sess-1", 3L, 7L, Status.ABANDONED, null, + request().getQnaRequests())); + + verify(interviewRepository).claimResultSave(3L, Status.ABANDONED); ArgumentCaptor saved = ArgumentCaptor.forClass(InterviewEntity.class); verify(interviewRepository).save(saved.capture()); - assertThat(saved.getValue().getStatus()).isEqualTo(Status.COMPLETED); + assertThat(saved.getValue().getStatus()).isEqualTo(Status.ABANDONED); + verify(questionRepository, org.mockito.Mockito.atLeastOnce()).save(any(QuestionEntity.class)); + } + + /** 이미 확정한 면접에 반대 상태나 상태 없는 재전송이 와도 처음 기록·상태를 그대로 두고 정상 응답한다. */ + @Test + void 이미_종료된_면접은_다시_온_요청의_상태가_무엇이든_건드리지_않는다() { + when(interviewRepository.findById(3L)).thenReturn(Optional.of(InterviewEntity.builder() + .interviewId(3L).userId(7L).sessionId("sess-1").status(Status.COMPLETED).build())); + + for (Status status : java.util.Arrays.asList(Status.ABANDONED, null, Status.IN_PROGRESS)) { + service.saveInterview(new SaveInterviewRequest("sess-1", 3L, 7L, status, null, List.of())); + } + + verify(interviewRepository, never()).claimResultSave(anyLong(), any()); + verify(interviewRepository, never()).save(any()); + verify(questionRepository, never()).deleteAllByInterviewId(anyLong()); } @Test @@ -317,11 +349,4 @@ private List savedAnswers() { verify(interviewRepository, never()).save(any()); } - /** 진행 중으로 남기면 다시 온 기록을 처음 것으로 보고 또 저장한다. */ - @Test - void 진행_중_상태로_와도_끝난_것으로_저장한다() { - service.saveInterview(new SaveInterviewRequest("sess-1", 3L, 7L, Status.IN_PROGRESS, null, List.of())); - - verify(interviewRepository).claimResultSave(3L, Status.COMPLETED); - } } From 7b83cdd09a772a03b420f347729543e47d696ea9 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Tue, 20 Oct 2026 21:30:03 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(analysis):=20=ED=96=89=EB=8F=99=20?= =?UTF-8?q?=EA=B4=80=EC=B0=B0=20behavior-v1=EA=B3=BC=20=EC=98=81=EC=83=81?= =?UTF-8?q?=20=EC=A2=85=EB=A3=8C=20=EB=AC=B8=EC=84=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 분석 서버 validation.py와 같은 규칙으로 BehaviorData를 타입·의미까지 검증한다(비율·coverage·사유·timeline 합·이벤트·경고·품질 판정). - 종료 문서 포장 상태 일치, 공개 오류 코드·retryable 사전, videoId·durationMs 묶음, NUL 문자를 검증한다. - Web PR #189의 계약 fixture 18개를 테스트 리소스로 가져와 정상·부정 변형을 검사한다. Co-Authored-By: Claude Opus 5.5 --- .../analysis/behavior/BehaviorData.java | 47 ++ .../analysis/behavior/BehaviorPolicy.java | 86 +++ .../analysis/behavior/BehaviorValidator.java | 330 ++++++++++++ .../analysis/behavior/JsonValues.java | 94 ++++ .../analysis/behavior/StrictJson.java | 83 +++ .../analysis/behavior/VideoPublicError.java | 13 + .../analysis/behavior/VideoPublicErrors.java | 89 ++++ .../behavior/VideoResultDocument.java | 107 ++++ .../behavior/VideoResultException.java | 27 + .../behavior/BehaviorContractTest.java | 387 ++++++++++++++ .../behavior/api-analyzing-previous.json | 498 ++++++++++++++++++ .../contracts/behavior/api-disabled.json | 14 + .../behavior/api-failed-previous.json | 274 ++++++++++ .../contracts/behavior/api-no-recording.json | 14 + .../contracts/behavior/api-partial.json | 498 ++++++++++++++++++ .../contracts/behavior/api-ready.json | 494 +++++++++++++++++ .../contracts/behavior/api-unavailable.json | 42 ++ .../contracts/behavior/api-waiting.json | 14 + .../contracts/behavior/api-zero-face.json | 484 +++++++++++++++++ .../contracts/behavior/behavior-partial.json | 233 ++++++++ .../contracts/behavior/behavior-ready.json | 231 ++++++++ .../behavior/behavior-zero-face.json | 226 ++++++++ .../contracts/behavior/callback-failed.json | 15 + .../contracts/behavior/callback-partial.json | 253 +++++++++ .../contracts/behavior/callback-ready.json | 251 +++++++++ .../behavior/callback-unavailable.json | 25 + .../behavior/callback-unconfigured.json | 25 + .../behavior/callback-zero-face.json | 246 +++++++++ 28 files changed, 5100 insertions(+) create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorData.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorPolicy.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorValidator.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/JsonValues.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/StrictJson.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicError.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicErrors.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultDocument.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultException.java create mode 100644 src/test/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorContractTest.java create mode 100644 src/test/resources/contracts/behavior/api-analyzing-previous.json create mode 100644 src/test/resources/contracts/behavior/api-disabled.json create mode 100644 src/test/resources/contracts/behavior/api-failed-previous.json create mode 100644 src/test/resources/contracts/behavior/api-no-recording.json create mode 100644 src/test/resources/contracts/behavior/api-partial.json create mode 100644 src/test/resources/contracts/behavior/api-ready.json create mode 100644 src/test/resources/contracts/behavior/api-unavailable.json create mode 100644 src/test/resources/contracts/behavior/api-waiting.json create mode 100644 src/test/resources/contracts/behavior/api-zero-face.json create mode 100644 src/test/resources/contracts/behavior/behavior-partial.json create mode 100644 src/test/resources/contracts/behavior/behavior-ready.json create mode 100644 src/test/resources/contracts/behavior/behavior-zero-face.json create mode 100644 src/test/resources/contracts/behavior/callback-failed.json create mode 100644 src/test/resources/contracts/behavior/callback-partial.json create mode 100644 src/test/resources/contracts/behavior/callback-ready.json create mode 100644 src/test/resources/contracts/behavior/callback-unavailable.json create mode 100644 src/test/resources/contracts/behavior/callback-unconfigured.json create mode 100644 src/test/resources/contracts/behavior/callback-zero-face.json diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorData.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorData.java new file mode 100644 index 00000000..10066741 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorData.java @@ -0,0 +1,47 @@ +package repit.repit_api_server.domain.userdata.analysis.behavior; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; +import java.util.Map; + +/** + * 영상 행동 관찰 결과 v1(behavior-v1). 분석 서버가 {@code result.analysis.data}에 싣는 문서를 {@link BehaviorValidator}가 + * 타입·의미까지 검증한 뒤에만 만든다. 웹에는 이 값을 그대로 내려준다. + * + *

지표는 연습용 관찰치다. 면접 점수·신뢰성·합격 가능성에 더하지 않고, 이 서버가 다시 계산하거나 고치지 않는다. + * 필드 모양은 분석 서버 {@code docs/behavior-data.schema.json}과 같다. 측정할 수 없는 지표의 {@code value}는 0이 아니라 + * null이다 — 유효한 0%와 구분해야 한다. + */ +@JsonInclude(JsonInclude.Include.ALWAYS) +public record BehaviorData( + String schemaVersion, + String modelVersion, + String thresholdsVersion, + String videoId, + int durationMs, + int sampleIntervalMs, + int sampledFrameCount, + int sampledDurationMs, + Map metrics, + List timeline, + List events, + boolean eventsTruncated, + List summary, + List warnings) { + + /** 지표 하나. value = 분자/분모, coverageRatio = 분모/전체 길이. 둘 다 소수점 6자리 반올림. */ + @JsonInclude(JsonInclude.Include.ALWAYS) + public record Metric(String status, Double value, int numeratorMs, int denominatorMs, double coverageRatio, + String unavailableReason) { + } + + public record Pair(int numeratorMs, int denominatorMs) { + } + + public record TimelineBin(int startMs, int endMs, Map metrics) { + } + + public record Event(String type, String sourceMetric, int startMs, int endMs) { + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorPolicy.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorPolicy.java new file mode 100644 index 00000000..0f233d8f --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorPolicy.java @@ -0,0 +1,86 @@ +package repit.repit_api_server.domain.userdata.analysis.behavior; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 행동 관찰 v1의 고정 값. 분석 서버 {@code app/core/common/behavior/policy.py}와 같아야 한다. + * + *

값을 바꾸면 같은 버전 이름으로 다른 결과가 나온다. 임계값·모델이 바뀌면 분석 서버가 버전을 올리고, 이 서버는 모르는 + * 버전을 받지 않는다. + */ +final class BehaviorPolicy { + + static final String SCHEMA_VERSION = "behavior-v1"; + static final String MODEL_VERSION = "mp-face-pose-v1"; + static final String THRESHOLDS_VERSION = "behavior-policy-v1"; + + static final int SAMPLE_MS = 200; + static final int MIN_DURATION_MS = 10_000; + static final double MIN_COVERAGE = 0.30; + static final double READY_COVERAGE = 0.80; + static final double READY_SAMPLE_COVERAGE = 0.90; + static final int MIN_EVENT_MS = 2_000; + static final int MAX_EVENTS = 60; + static final int MAX_BINS = 60; + static final int MAX_DURATION_MS = 3_600_000; + static final int MAX_SAMPLED_FRAMES = 18_000; + static final int MAX_SUMMARY = 3; + static final int MAX_SUMMARY_CHARS = 200; + static final int MAX_WARNINGS = 8; + static final int MAX_MODEL_VERSION_CHARS = 100; + static final int MAX_VIDEO_ID_CHARS = 200; + // 비율은 소수점 6자리로 반올림해 온다. 이 차이까지는 같은 값으로 본다. + static final double ROUNDING_TOLERANCE = 1e-6; + + static final String FACE = "faceVisibleRatio"; + static final String HEAD = "headFacingRatio"; + static final String SHOULDER = "shoulderLevelRatio"; + static final String UPPER_BODY = "upperBodyStableRatio"; + static final String SMILE = "smileShapeRatio"; + static final List KEYS = List.of(FACE, HEAD, SHOULDER, UPPER_BODY, SMILE); + + /** 이벤트 종류 → 그 이벤트가 깎아 먹는 지표. 미소는 이벤트가 없다. */ + static final Map EVENT_METRICS; + + static { + Map events = new LinkedHashMap<>(); + events.put("FACE_NOT_VISIBLE", FACE); + events.put("HEAD_TURNED", HEAD); + events.put("SHOULDER_TILTED", SHOULDER); + events.put("UPPER_BODY_MOVEMENT", UPPER_BODY); + EVENT_METRICS = Map.copyOf(events); + } + + static final Set WARNINGS = Set.of("LOW_SAMPLE_COVERAGE", "MULTIPLE_PEOPLE", "FACE_TOO_SMALL", + "LOW_FACE_COVERAGE", "LOW_POSE_COVERAGE", "COMPONENT_FAILURE", "EVENTS_TRUNCATED"); + + static final String AVAILABLE = "AVAILABLE"; + static final String UNAVAILABLE = "UNAVAILABLE"; + + private BehaviorPolicy() { + } + + /** 분자/분모를 소수점 6자리에서 반올림(ROUND_HALF_UP). 분모가 0이면 0. */ + static double fraction(long numerator, long denominator) { + if (denominator == 0) { + return 0.0; + } + return BigDecimal.valueOf(numerator).divide(BigDecimal.valueOf(denominator), 6, RoundingMode.HALF_UP).doubleValue(); + } + + /** 시간 요약 구간 하나의 폭. 최대 60개가 되도록 1초 단위로 넓히고, 10초보다 좁히지 않는다. */ + static int binWidth(int durationMs) { + return Math.max(10_000, ((durationMs + 59_999) / 60_000) * 1_000); + } + + /** 지표를 측정할 수 있는지. 전체 10초 이상, 지표 분모 10초 이상, 분모가 전체의 30% 이상. */ + static boolean available(long denominatorMs, long durationMs) { + return durationMs >= MIN_DURATION_MS && denominatorMs >= MIN_DURATION_MS + && (double) denominatorMs / durationMs >= MIN_COVERAGE; + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorValidator.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorValidator.java new file mode 100644 index 00000000..3ad33877 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorValidator.java @@ -0,0 +1,330 @@ +package repit.repit_api_server.domain.userdata.analysis.behavior; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import static repit.repit_api_server.domain.userdata.analysis.behavior.BehaviorPolicy.*; +import static repit.repit_api_server.domain.userdata.analysis.behavior.StrictJson.*; + +/** + * 행동 관찰 문서(behavior-v1)를 타입과 의미까지 검증한다. + * + *

구조 스키마만으로는 끝내지 않는다. 비율과 분자·분모, 측정 범위(coverage), 상태·미측정 사유, 시간 구간의 경계와 합, + * 이벤트의 순서·범위·길이·상한, 경고까지 분석 서버가 만든 규칙대로 서로 맞는지 본다 — 분석 서버 + * {@code app/core/common/behavior/validation.py}와 같은 규칙이다. 모양만 맞고 숫자가 서로 어긋난 결과를 저장하면 화면이 + * 거짓 수치를 그린다. + * + *

콜백과 작업 조회 복구, 저장된 결과를 다시 읽는 조회가 모두 이것 하나를 쓴다. + */ +public final class BehaviorValidator { + + private static final List FIELDS = List.of("schemaVersion", "modelVersion", "thresholdsVersion", "videoId", + "durationMs", "sampleIntervalMs", "sampledFrameCount", "sampledDurationMs", "metrics", "timeline", "events", + "eventsTruncated", "summary", "warnings"); + private static final List METRIC_FIELDS = + List.of("status", "value", "numeratorMs", "denominatorMs", "coverageRatio", "unavailableReason"); + private static final List PAIR_FIELDS = List.of("numeratorMs", "denominatorMs"); + private static final List BIN_FIELDS = List.of("startMs", "endMs", "metrics"); + private static final List EVENT_FIELDS = List.of("type", "sourceMetric", "startMs", "endMs"); + private static final Set REASONS = Set.of("INSUFFICIENT_DURATION", "NO_SINGLE_FACE", + "HEAD_POSE_UNAVAILABLE", "SHOULDERS_NOT_VISIBLE", "BLENDSHAPES_UNAVAILABLE", "LOW_COVERAGE"); + + private BehaviorValidator() { + } + + /** + * 검증을 통과한 문서. 통과하지 못하면 어긋난 위치와 규칙을 담은 {@link VideoResultException}. + * + * @param raw Jackson이 타입 없이 읽은 {@code result.analysis.data} + */ + public static BehaviorData parse(Object raw) { + BehaviorData data = shape(raw); + coherent(data); + return data; + } + + /** ready = 다섯 지표 모두 측정 가능하고 각 범위 80% 이상, 표본 범위 90% 이상, 구성요소 실패 없음. 아니면 partial. */ + public static String quality(BehaviorData data) { + double duration = data.durationMs(); + boolean ready = data.metrics().values().stream() + .allMatch(m -> AVAILABLE.equals(m.status()) && m.denominatorMs() / duration >= READY_COVERAGE) + && data.sampledDurationMs() / duration >= READY_SAMPLE_COVERAGE + && !data.warnings().contains("COMPONENT_FAILURE"); + return ready ? "ready" : "partial"; + } + + // ---- 타입·범위(JSON Schema) ---- + + private static BehaviorData shape(Object raw) { + Map d = object(raw, "$"); + exactKeys(d, FIELDS, "$"); + constant(d.get("schemaVersion"), SCHEMA_VERSION, "$.schemaVersion"); + text(d.get("modelVersion"), MAX_MODEL_VERSION_CHARS, "$.modelVersion"); + // 모델이 바뀌면 같은 지표 이름이라도 다른 값이 나온다. 이 서버가 아는 모델의 결과만 받는다. + constant(d.get("modelVersion"), MODEL_VERSION, "$.modelVersion"); + constant(d.get("thresholdsVersion"), THRESHOLDS_VERSION, "$.thresholdsVersion"); + String videoId = text(d.get("videoId"), MAX_VIDEO_ID_CHARS, "$.videoId"); + int duration = (int) integer(d.get("durationMs"), 1, MAX_DURATION_MS, "$.durationMs"); + integer(d.get("sampleIntervalMs"), SAMPLE_MS, SAMPLE_MS, "$.sampleIntervalMs"); + int count = (int) integer(d.get("sampledFrameCount"), 0, MAX_SAMPLED_FRAMES, "$.sampledFrameCount"); + int sampled = (int) integer(d.get("sampledDurationMs"), 0, MAX_DURATION_MS, "$.sampledDurationMs"); + + Map rawMetrics = object(d.get("metrics"), "$.metrics"); + exactKeys(rawMetrics, KEYS, "$.metrics"); + Map metrics = new LinkedHashMap<>(); + for (String key : KEYS) { + metrics.put(key, metric(rawMetrics.get(key), "$.metrics." + key)); + } + + List rawBins = array(d.get("timeline"), 1, MAX_BINS, "$.timeline"); + List timeline = new ArrayList<>(); + for (int i = 0; i < rawBins.size(); i++) { + timeline.add(bin(rawBins.get(i), "$.timeline[" + i + "]")); + } + + List rawEvents = array(d.get("events"), 0, MAX_EVENTS, "$.events"); + List events = new ArrayList<>(); + for (int i = 0; i < rawEvents.size(); i++) { + events.add(event(rawEvents.get(i), "$.events[" + i + "]")); + } + + boolean truncated = bool(d.get("eventsTruncated"), "$.eventsTruncated"); + + List rawSummary = array(d.get("summary"), 0, MAX_SUMMARY, "$.summary"); + List summary = new ArrayList<>(); + for (int i = 0; i < rawSummary.size(); i++) { + summary.add(text(rawSummary.get(i), MAX_SUMMARY_CHARS, "$.summary[" + i + "]")); + } + + List rawWarnings = array(d.get("warnings"), 0, MAX_WARNINGS, "$.warnings"); + List warnings = new ArrayList<>(); + for (int i = 0; i < rawWarnings.size(); i++) { + String warning = text(rawWarnings.get(i), 40, "$.warnings[" + i + "]"); + require(WARNINGS.contains(warning), "$.warnings[" + i + "]", "unknown warning"); + warnings.add(warning); + } + + return new BehaviorData(SCHEMA_VERSION, MODEL_VERSION, THRESHOLDS_VERSION, videoId, duration, SAMPLE_MS, + count, sampled, metrics, timeline, events, truncated, summary, warnings); + } + + private static BehaviorData.Metric metric(Object raw, String path) { + Map m = object(raw, path); + exactKeys(m, METRIC_FIELDS, path); + Object rawStatus = m.get("status"); + require(AVAILABLE.equals(rawStatus) || UNAVAILABLE.equals(rawStatus), path + ".status", "unknown status"); + Double value = m.get("value") == null ? null : number(m.get("value"), 0, 1, path + ".value"); + int numerator = (int) integer(m.get("numeratorMs"), 0, MAX_DURATION_MS, path + ".numeratorMs"); + int denominator = (int) integer(m.get("denominatorMs"), 0, MAX_DURATION_MS, path + ".denominatorMs"); + require(numerator <= denominator, path, "numerator exceeds denominator"); + double coverage = number(m.get("coverageRatio"), 0, 1, path + ".coverageRatio"); + Object reason = m.get("unavailableReason"); + require(reason == null || REASONS.contains(reason), path + ".unavailableReason", "unknown reason"); + return new BehaviorData.Metric((String) rawStatus, value, numerator, denominator, coverage, (String) reason); + } + + private static BehaviorData.Pair pair(Object raw, String path) { + Map p = object(raw, path); + exactKeys(p, PAIR_FIELDS, path); + int numerator = (int) integer(p.get("numeratorMs"), 0, MAX_DURATION_MS, path + ".numeratorMs"); + int denominator = (int) integer(p.get("denominatorMs"), 0, MAX_DURATION_MS, path + ".denominatorMs"); + require(numerator <= denominator, path, "numerator exceeds denominator"); + return new BehaviorData.Pair(numerator, denominator); + } + + private static BehaviorData.TimelineBin bin(Object raw, String path) { + Map b = object(raw, path); + exactKeys(b, BIN_FIELDS, path); + int start = (int) integer(b.get("startMs"), 0, MAX_DURATION_MS, path + ".startMs"); + int end = (int) integer(b.get("endMs"), 1, MAX_DURATION_MS, path + ".endMs"); + Map rawPairs = object(b.get("metrics"), path + ".metrics"); + exactKeys(rawPairs, KEYS, path + ".metrics"); + Map pairs = new LinkedHashMap<>(); + for (String key : KEYS) { + pairs.put(key, pair(rawPairs.get(key), path + ".metrics." + key)); + } + return new BehaviorData.TimelineBin(start, end, pairs); + } + + private static BehaviorData.Event event(Object raw, String path) { + Map e = object(raw, path); + exactKeys(e, EVENT_FIELDS, path); + Object type = e.get("type"); + require(EVENT_METRICS.containsKey(type), path + ".type", "unknown event type"); + Object source = e.get("sourceMetric"); + require(EVENT_METRICS.containsValue(source), path + ".sourceMetric", "unknown source metric"); + int start = (int) integer(e.get("startMs"), 0, MAX_DURATION_MS, path + ".startMs"); + int end = (int) integer(e.get("endMs"), 1, MAX_DURATION_MS, path + ".endMs"); + return new BehaviorData.Event((String) type, (String) source, start, end); + } + + // ---- 의미(분석 서버 validation.py) ---- + + private static void coherent(BehaviorData data) { + int duration = data.durationMs(); + int count = data.sampledFrameCount(); + int sampled = data.sampledDurationMs(); + require(count <= (duration + SAMPLE_MS - 1) / SAMPLE_MS, "$.sampledFrameCount", "sample count overflow"); + require((count == 0) == (sampled == 0), "$.sampledDurationMs", "sample count/duration mismatch"); + require(Math.max(0, (long) (count - 1) * SAMPLE_MS) <= sampled + && sampled <= Math.min(duration, (long) count * SAMPLE_MS), + "$.sampledDurationMs", "slot weights mismatch"); + + for (String key : KEYS) { + metric(key, data); + } + require(data.metrics().values().stream().anyMatch(m -> AVAILABLE.equals(m.status())), + "$.metrics", "empty successful result"); + timeline(data); + events(data); + warnings(data); + } + + private static void metric(String key, BehaviorData data) { + String path = "$.metrics." + key; + BehaviorData.Metric metric = data.metrics().get(key); + int duration = data.durationMs(); + int denominator = metric.denominatorMs(); + require(denominator <= data.sampledDurationMs(), path, "metric exceeds sampled duration"); + require(Math.abs(metric.coverageRatio() - fraction(denominator, duration)) <= ROUNDING_TOLERANCE, + path + ".coverageRatio", "coverage mismatch"); + boolean measurable = available(denominator, duration); + require(AVAILABLE.equals(metric.status()) == measurable, path + ".status", "metric status mismatch"); + if (measurable) { + require(metric.value() != null && metric.unavailableReason() == null, path, "available metric missing value"); + require(Math.abs(metric.value() - fraction(metric.numeratorMs(), denominator)) <= ROUNDING_TOLERANCE, + path + ".value", "value mismatch"); + } else { + require(metric.value() == null && metric.unavailableReason() != null, path, + "unavailable metric needs reason"); + require(expectedReason(key, denominator, data).equals(metric.unavailableReason()), + path + ".unavailableReason", "metric unavailable reason mismatch"); + } + if (!FACE.equals(key)) { + require(denominator <= data.metrics().get(FACE).numeratorMs(), path, "metric exceeds single-face duration"); + } + } + + /** 사유는 짧은 영상 → 단일 얼굴 없음 → 그 모델의 출력 없음 → 관찰 범위 부족 순서로 정한다. */ + private static String expectedReason(String key, int denominator, BehaviorData data) { + if (data.durationMs() < MIN_DURATION_MS) { + return "INSUFFICIENT_DURATION"; + } + if (!FACE.equals(key) && data.metrics().get(FACE).numeratorMs() == 0) { + return "NO_SINGLE_FACE"; + } + if (denominator == 0) { + return switch (key) { + case HEAD -> "HEAD_POSE_UNAVAILABLE"; + case SHOULDER, UPPER_BODY -> "SHOULDERS_NOT_VISIBLE"; + case SMILE -> "BLENDSHAPES_UNAVAILABLE"; + default -> "LOW_COVERAGE"; + }; + } + return "LOW_COVERAGE"; + } + + private static void timeline(BehaviorData data) { + int duration = data.durationMs(); + int width = binWidth(duration); + List bins = data.timeline(); + require(bins.size() == (duration + width - 1) / width, "$.timeline", "timeline size mismatch"); + Map totals = new HashMap<>(); + KEYS.forEach(key -> totals.put(key, new long[2])); + for (int i = 0; i < bins.size(); i++) { + String path = "$.timeline[" + i + "]"; + BehaviorData.TimelineBin bin = bins.get(i); + require(bin.startMs() == (long) i * width, path + ".startMs", "timeline gap"); + require(bin.endMs() == Math.min((long) (i + 1) * width, duration), path + ".endMs", "timeline boundary mismatch"); + for (String key : KEYS) { + BehaviorData.Pair pair = bin.metrics().get(key); + require(pair.denominatorMs() <= bin.endMs() - bin.startMs(), path + ".metrics." + key, + "bin duration overflow"); + totals.get(key)[0] += pair.numeratorMs(); + totals.get(key)[1] += pair.denominatorMs(); + } + } + for (String key : KEYS) { + BehaviorData.Metric metric = data.metrics().get(key); + long[] total = totals.get(key); + require(total[0] == metric.numeratorMs() && total[1] == metric.denominatorMs(), "$.metrics." + key, + "timeline sum mismatch"); + } + } + + private static void events(BehaviorData data) { + List events = data.events(); + Map ends = new HashMap<>(); + for (int i = 0; i < events.size(); i++) { + String path = "$.events[" + i + "]"; + BehaviorData.Event event = events.get(i); + if (i > 0) { + require(order(events.get(i - 1), event) <= 0, path, "event order mismatch"); + } + require(EVENT_METRICS.get(event.type()).equals(event.sourceMetric()), path + ".sourceMetric", + "event metric mismatch"); + require(AVAILABLE.equals(data.metrics().get(event.sourceMetric()).status()), path, + "event for unavailable metric"); + require(0 <= event.startMs() && event.startMs() < event.endMs() && event.endMs() <= data.durationMs(), + path, "invalid event range"); + require(event.endMs() - event.startMs() >= MIN_EVENT_MS, path, "event too short"); + require(ends.getOrDefault(event.type(), 0) <= event.startMs(), path, "overlapping event"); + ends.put(event.type(), event.endMs()); + } + // 한 구간 안에서 같은 종류 이벤트가 차지한 시간은 그 지표가 조건 밖이었던 시간(분모 - 분자)을 넘을 수 없다. + for (int i = 0; i < data.timeline().size(); i++) { + BehaviorData.TimelineBin bin = data.timeline().get(i); + for (Map.Entry kind : EVENT_METRICS.entrySet()) { + long marked = 0; + for (BehaviorData.Event event : events) { + if (event.type().equals(kind.getKey())) { + marked += Math.max(0, Math.min(event.endMs(), bin.endMs()) - Math.max(event.startMs(), bin.startMs())); + } + } + BehaviorData.Pair pair = bin.metrics().get(kind.getValue()); + require(marked <= pair.denominatorMs() - pair.numeratorMs(), "$.timeline[" + i + "]", + "event contradicts timeline"); + } + } + } + + /** 이벤트 정렬 기준: 시작 → 종류 → 끝. */ + private static int order(BehaviorData.Event a, BehaviorData.Event b) { + int byStart = Integer.compare(a.startMs(), b.startMs()); + if (byStart != 0) { + return byStart; + } + int byType = a.type().compareTo(b.type()); + return byType != 0 ? byType : Integer.compare(a.endMs(), b.endMs()); + } + + private static void warnings(BehaviorData data) { + List warnings = data.warnings(); + require(warnings.equals(new ArrayList<>(new TreeSet<>(warnings))), "$.warnings", "warning order or duplicates"); + require(warnings.contains("EVENTS_TRUNCATED") == data.eventsTruncated(), "$.eventsTruncated", + "truncation mismatch"); + // 앞 60개만 남겼다는 표시다. 60개보다 적으면 잘린 것이 아니다. + require(!data.eventsTruncated() || data.events().size() == MAX_EVENTS, "$.eventsTruncated", + "truncated events must be full"); + double duration = data.durationMs(); + require(warnings.contains("LOW_SAMPLE_COVERAGE") == (data.sampledDurationMs() / duration < READY_SAMPLE_COVERAGE), + "$.warnings", "sampling warning"); + require(warnings.contains("LOW_FACE_COVERAGE") == low(data, HEAD, SMILE), "$.warnings", "coverage warning mismatch"); + require(warnings.contains("LOW_POSE_COVERAGE") == low(data, SHOULDER, UPPER_BODY), "$.warnings", + "coverage warning mismatch"); + } + + private static boolean low(BehaviorData data, String... keys) { + for (String key : keys) { + if (data.metrics().get(key).denominatorMs() / (double) data.durationMs() < READY_COVERAGE) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/JsonValues.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/JsonValues.java new file mode 100644 index 00000000..1ddbd848 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/JsonValues.java @@ -0,0 +1,94 @@ +package repit.repit_api_server.domain.userdata.analysis.behavior; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** Jackson이 타입 없이 읽은 JSON 값을 다루는 도구. */ +public final class JsonValues { + + private JsonValues() { + } + + /** + * 두 JSON 값이 의미상 같은지. 객체는 키 순서와 상관없이 같은 키·같은 값이면 같고, 배열은 순서까지 같아야 한다. 숫자는 + * 표기가 아니라 값으로 비교한다(1과 1.0, Integer와 Long이 같다) — jsonb로 저장했다 읽으면 숫자 타입이 바뀔 수 있다. + */ + public static boolean same(Object a, Object b) { + if (a == null || b == null) { + return a == b; + } + if (a instanceof Map left && b instanceof Map right) { + if (left.size() != right.size()) { + return false; + } + for (Map.Entry entry : left.entrySet()) { + if (!right.containsKey(entry.getKey()) || !same(entry.getValue(), right.get(entry.getKey()))) { + return false; + } + } + return true; + } + if (a instanceof List left && b instanceof List right) { + if (left.size() != right.size()) { + return false; + } + Iterator l = left.iterator(); + Iterator r = right.iterator(); + while (l.hasNext()) { + if (!same(l.next(), r.next())) { + return false; + } + } + return true; + } + if (a instanceof Number left && b instanceof Number right) { + BigDecimal x = decimal(left); + BigDecimal y = decimal(right); + return x != null && y != null && x.compareTo(y) == 0; + } + return a.equals(b); + } + + /** + * 문자열 어딘가에 NUL(U+0000)이 있는지. PostgreSQL은 text·jsonb에 NUL을 넣지 못해 저장이 매번 실패한다 — 그런 결과는 + * 계약 위반으로 따로 남겨야 같은 실패를 끝없이 되풀이하지 않는다. + */ + public static boolean containsNul(Object value) { + if (value instanceof String text) { + return text.indexOf('\u0000') >= 0; + } + if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + if (containsNul(entry.getKey()) || containsNul(entry.getValue())) { + return true; + } + } + return false; + } + if (value instanceof List list) { + for (Object item : list) { + if (containsNul(item)) { + return true; + } + } + } + return false; + } + + private static BigDecimal decimal(Number number) { + if (number instanceof BigDecimal decimal) { + return decimal; + } + if (number instanceof BigInteger integer) { + return new BigDecimal(integer); + } + if (number instanceof Double || number instanceof Float) { + double value = number.doubleValue(); + return Double.isFinite(value) ? BigDecimal.valueOf(value) : null; + } + return BigDecimal.valueOf(number.longValue()); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/StrictJson.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/StrictJson.java new file mode 100644 index 00000000..a08e8dc8 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/StrictJson.java @@ -0,0 +1,83 @@ +package repit.repit_api_server.domain.userdata.analysis.behavior; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * Jackson이 타입 없이 읽은 JSON(Map·List·String·Number·Boolean·null)을 계약 타입 그대로만 받는다. + * + *

숫자 문자열("60000"), 문자열 불리언("false"), 정수 자리의 실수(60000.5), NaN·Infinity를 고쳐 받지 않는다. 분석 서버는 + * 엄격한 타입으로 내보내므로 다른 타입이 왔다면 계약이 깨진 것이다. + */ +final class StrictJson { + + private StrictJson() { + } + + static void require(boolean condition, String path, String rule) { + if (!condition) { + throw new VideoResultException(path, rule); + } + } + + @SuppressWarnings("unchecked") + static Map object(Object value, String path) { + require(value instanceof Map, path, "object required"); + return (Map) value; + } + + @SuppressWarnings("unchecked") + static List array(Object value, int min, int max, String path) { + require(value instanceof List, path, "array required"); + List list = (List) value; + require(list.size() >= min && list.size() <= max, path, "array size out of range"); + return list; + } + + /** 정확히 이 필드들만 있어야 한다. 빠진 것도, 정의되지 않은 것도 받지 않는다. */ + static void exactKeys(Map object, Collection keys, String path) { + require(object.size() == keys.size() && object.keySet().containsAll(keys), path, "unexpected or missing fields"); + } + + /** 키가 있고 값이 null이어야 한다. */ + static void nullField(Map object, String key, String path) { + require(object.containsKey(key) && object.get(key) == null, path + "." + key, "null required"); + } + + static long integer(Object value, long min, long max, String path) { + require(value instanceof Integer || value instanceof Long || value instanceof BigInteger, path, "integer required"); + BigInteger number = value instanceof BigInteger big ? big : BigInteger.valueOf(((Number) value).longValue()); + require(number.compareTo(BigInteger.valueOf(min)) >= 0 && number.compareTo(BigInteger.valueOf(max)) <= 0, + path, "integer out of range"); + return number.longValue(); + } + + static double number(Object value, double min, double max, String path) { + require(value instanceof Number, path, "number required"); + double number = value instanceof BigDecimal decimal ? decimal.doubleValue() : ((Number) value).doubleValue(); + require(Double.isFinite(number), path, "finite number required"); + require(number >= min && number <= max, path, "number out of range"); + return number; + } + + /** 비어 있지 않은 문자열. 길이는 UTF-16 단위가 아니라 유니코드 문자 수로 센다(분석 서버·웹과 같다). */ + static String text(Object value, int maxChars, String path) { + require(value instanceof String, path, "string required"); + String text = (String) value; + int chars = text.codePointCount(0, text.length()); + require(chars >= 1 && chars <= maxChars, path, "string length out of range"); + return text; + } + + static boolean bool(Object value, String path) { + require(value instanceof Boolean, path, "boolean required"); + return (Boolean) value; + } + + static void constant(Object value, String expected, String path) { + require(expected.equals(value), path, "unsupported value"); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicError.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicError.java new file mode 100644 index 00000000..6ff452bd --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicError.java @@ -0,0 +1,13 @@ +package repit.repit_api_server.domain.userdata.analysis.behavior; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * 웹에 보여 줄 영상 분석 오류. 코드별 공개 사전에서만 만든다 — DB에 남긴 내부 메시지, 예외 문자열, 분석 서버 응답 본문을 + * 그대로 내보내지 않는다. + * + * @param retryable 같은 원본을 새 요청으로 다시 분석하면 나을 수 있는 원인인지. 재분석 버튼을 열지는 canRetry가 정한다. + */ +@JsonInclude(JsonInclude.Include.ALWAYS) +public record VideoPublicError(String code, String message, boolean retryable) { +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicErrors.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicErrors.java new file mode 100644 index 00000000..cf83d823 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicErrors.java @@ -0,0 +1,89 @@ +package repit.repit_api_server.domain.userdata.analysis.behavior; + +import java.util.Map; + +import static repit.repit_api_server.domain.userdata.analysis.behavior.StrictJson.*; + +/** + * 영상 분석 공개 오류 사전. + * + *

분석 서버 코드는 {@code app/core/common/video/errors.py}와 같다. retryable은 "같은 영상을 새 요청 id로 다시 요청하면 + * 나을 수 있는지"다. 같은 원본의 측정 부족(BEHAVIOR_NOT_MEASURABLE)은 다시 해도 같으므로 false다. 분석 서버가 이 표와 다른 + * retryable을 보내면 계약이 깨진 것으로 보고 결과로 받지 않는다. + * + *

이 서버가 결과 없이 닫은 사유(접수 미확인, 작업 없음, 처리 상한 초과 등)도 화면용 코드로 옮긴다. 그 밖의 내부 코드는 + * UNKNOWN_ERROR로만 내보낸다 — DB에 적힌 임의 문자열을 화면 코드로 쓰지 않는다. + */ +public final class VideoPublicErrors { + + public static final String UNKNOWN_ERROR = "UNKNOWN_ERROR"; + + private static final Map ANALYSIS = Map.ofEntries( + entry("SOURCE_ACCESS_DENIED_OR_EXPIRED", "영상 파일에 접근할 수 없습니다. 새 다운로드 URL 로 다시 요청해 주세요.", true), + entry("SOURCE_DOWNLOAD_FAILED", "영상 파일을 내려받지 못했습니다.", true), + entry("EXTERNAL_SERVICE_UNAVAILABLE", "외부 서비스를 일시적으로 사용할 수 없습니다.", true), + entry("PROCESSING_TIMEOUT", "영상 처리 시간이 제한을 넘었습니다.", true), + entry("SOURCE_NOT_FOUND", "영상 파일을 찾을 수 없습니다.", false), + entry("SOURCE_SIZE_MISMATCH", "영상 파일 크기가 요청한 크기와 다릅니다.", false), + entry("VIDEO_LIMIT_EXCEEDED", "영상의 크기·길이·해상도·프레임률 제한을 넘었습니다.", false), + entry("VIDEO_FORMAT_UNSUPPORTED", "지원하지 않는 영상 형식입니다. WebM(VP8/VP9) 또는 MP4(H.264)만 지원합니다.", false), + entry("VIDEO_DECODE_FAILED", "영상 파일이 손상되었거나 해석할 수 없습니다.", false), + entry("INTERNAL_ERROR", "영상 분석 서버 내부 오류가 발생했습니다.", false), + entry("ANALYZER_NOT_CONFIGURED", "영상 행동 분석기가 아직 연결되지 않았습니다.", false), + entry("BEHAVIOR_NOT_MEASURABLE", "이 영상에서는 행동 지표를 측정할 수 있는 구간이 충분하지 않습니다.", false)); + + // 이 서버가 결과 없이 닫은 사유. 코드 값은 VideoAnalysisService가 error_code에 남기는 것과 같다. + private static final Map SERVICE = Map.ofEntries( + entry("NOT_ACKNOWLEDGED", "분석 서버가 영상 분석 요청을 접수했는지 확인하지 못했습니다.", true), + entry("JOB_NOT_FOUND", "분석 서버에서 영상 분석 작업을 찾지 못했습니다.", true), + entry("PROCESSING_OVERDUE", "영상 분석이 제한 시간 안에 끝나지 않았습니다.", true), + entry("INVALID_RESULT", "영상 분석 결과의 형식을 확인하지 못했습니다.", false), + entry("RESULT_EXPIRED", "영상 분석 결과의 보관 기간이 지났습니다.", false)); + + private static final VideoPublicError UNKNOWN = new VideoPublicError(UNKNOWN_ERROR, "영상 분석을 마치지 못했습니다.", false); + + private VideoPublicErrors() { + } + + /** 화면에 내보낼 오류. 모르는 코드나 내부 사유는 UNKNOWN_ERROR다. */ + public static VideoPublicError forDisplay(String storedCode) { + if (storedCode == null) { + return UNKNOWN; + } + VideoPublicError known = ANALYSIS.get(storedCode); + if (known == null) { + known = SERVICE.get(storedCode); + } + return known == null ? UNKNOWN : known; + } + + /** 분석 서버가 알릴 수 있는 공개 코드인지. */ + public static boolean isAnalysisCode(String code) { + return code != null && ANALYSIS.containsKey(code); + } + + /** 분석 서버 공개 코드의 retryable. 모르는 코드면 null. */ + public static Boolean retryableOf(String code) { + VideoPublicError known = code == null ? null : ANALYSIS.get(code); + return known == null ? null : known.retryable(); + } + + /** + * 분석 서버가 보낸 오류 객체를 검증한다. 정확히 code·message·retryable만 있어야 하고, 코드는 공개 사전에 있어야 하며, + * retryable은 엄격한 불리언으로 사전 값과 같아야 한다. 메시지는 1~1000자 문자열이면 받되 화면에는 쓰지 않는다. + */ + public static String validateRemote(Object raw, String path) { + Map error = object(raw, path); + exactKeys(error, java.util.List.of("code", "message", "retryable"), path); + Object code = error.get("code"); + require(code instanceof String && ANALYSIS.containsKey(code), path + ".code", "unknown public error code"); + text(error.get("message"), 1000, path + ".message"); + boolean retryable = bool(error.get("retryable"), path + ".retryable"); + require(retryable == ANALYSIS.get(code).retryable(), path + ".retryable", "retryable mismatch"); + return (String) code; + } + + private static Map.Entry entry(String code, String message, boolean retryable) { + return Map.entry(code, new VideoPublicError(code, message, retryable)); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultDocument.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultDocument.java new file mode 100644 index 00000000..3ab6cfca --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultDocument.java @@ -0,0 +1,107 @@ +package repit.repit_api_server.domain.userdata.analysis.behavior; + +import java.util.Map; +import java.util.Set; + +import static repit.repit_api_server.domain.userdata.analysis.behavior.StrictJson.*; + +/** + * 분석 서버의 영상 분석 종료 문서(콜백 본문, 작업 조회의 result) 전체를 검증한다. + * + *

포장의 상태가 서로 맞는지 본다 — 최상위·result·result.analysis의 status가 같고, ready/partial은 행동 데이터가 있고 오류가 + * 없으며, unavailable은 데이터 없이 공개 오류가 있고, failed는 result 없이 최상위 오류만 있다. 행동 데이터는 + * {@link BehaviorValidator}로 검증하고, 데이터의 videoId·durationMs가 바깥 result와 요청한 영상에 묶여 있는지, 콜백 상태가 + * 데이터의 품질 판정(ready/partial)과 같은지까지 본다. + * + *

요청·작업·세션·면접·사용자 id의 대조는 부르는 쪽({@code VideoAnalysisCallbackHandler})이 먼저 한다. 그쪽은 어긋난 + * 이유를 따로(ID_MISMATCH) 남기기 때문이다. 포장에 계약에 없는 필드가 더 있으면 받는다(하위 호환). 행동 데이터 안은 고정 + * 계약이라 받지 않는다. + */ +public final class VideoResultDocument { + + public static final String SCHEMA_VERSION = "1"; + public static final Set STATUSES = Set.of("ready", "partial", "unavailable", "failed"); + + /** + * 검증을 통과한 문서에서 저장·표시에 쓰는 값. + * + * @param status ready / partial / unavailable / failed + * @param errorCode 공개 오류 코드. ready/partial이면 null + * @param errorRetryable 공개 사전의 retryable. ready/partial이면 null + * @param errorMessage 분석 서버가 보낸 메시지. 내부 기록용이며 화면에는 쓰지 않는다 + * @param behavior ready/partial의 행동 데이터 + */ + public record Accepted(String status, String errorCode, Boolean errorRetryable, String errorMessage, + BehaviorData behavior) { + } + + private VideoResultDocument() { + } + + /** + * @param body 콜백 본문(또는 작업 조회의 result) + * @param expectedVideoId 이 요청으로 보낸 영상(recording_id) + */ + public static Accepted validate(Map body, String expectedVideoId) { + require(!JsonValues.containsNul(body), "$", "NUL character in string"); + constant(body.get("schemaVersion"), SCHEMA_VERSION, "$.schemaVersion"); + Object rawStatus = body.get("status"); + require(rawStatus instanceof String && STATUSES.contains(rawStatus), "$.status", "unknown status"); + String status = (String) rawStatus; + + if (status.equals("failed")) { + nullField(body, "result", "$"); + String code = VideoPublicErrors.validateRemote(body.get("error"), "$.error"); + return failure(status, code, body.get("error")); + } + + nullField(body, "error", "$"); + Map result = object(body.get("result"), "$.result"); + for (String key : new String[]{"videoId", "durationMs", "status", "analysis", "error"}) { + require(result.containsKey(key), "$.result." + key, "missing field"); + } + String videoId = text(result.get("videoId"), 200, "$.result.videoId"); + require(videoId.equals(expectedVideoId), "$.result.videoId", "video mismatch"); + require(status.equals(result.get("status")), "$.result.status", "status mismatch"); + Long durationMs = result.get("durationMs") == null + ? null + : integer(result.get("durationMs"), 1, BehaviorPolicy.MAX_DURATION_MS, "$.result.durationMs"); + + Map analysis = object(result.get("analysis"), "$.result.analysis"); + for (String key : new String[]{"status", "data", "error"}) { + require(analysis.containsKey(key), "$.result.analysis." + key, "missing field"); + } + require(status.equals(analysis.get("status")), "$.result.analysis.status", "status mismatch"); + + if (status.equals("unavailable")) { + nullField(analysis, "data", "$.result.analysis"); + String code = VideoPublicErrors.validateRemote(analysis.get("error"), "$.result.analysis.error"); + if (result.get("error") != null) { + // 파일 문제는 같은 공개 원인이 result.error와 analysis.error에 함께 온다. + VideoPublicErrors.validateRemote(result.get("error"), "$.result.error"); + require(JsonValues.same(result.get("error"), analysis.get("error")), "$.result.error", + "file error consistency"); + } + return failure(status, code, analysis.get("error")); + } + + // ready / partial: 부분 결과도 종료다. 지표별 미측정 사유는 데이터 안에 있고 오류는 없다. + nullField(analysis, "error", "$.result.analysis"); + nullField(result, "error", "$.result"); + BehaviorData data; + try { + data = BehaviorValidator.parse(analysis.get("data")); + } catch (VideoResultException e) { + throw new VideoResultException("$.result.analysis.data" + e.path().substring(1), e.rule()); + } + require(data.videoId().equals(videoId), "$.result.analysis.data.videoId", "video binding mismatch"); + require(durationMs != null && durationMs == data.durationMs(), "$.result.durationMs", "duration binding mismatch"); + require(BehaviorValidator.quality(data).equals(status), "$.status", "quality status mismatch"); + return new Accepted(status, null, null, null, data); + } + + private static Accepted failure(String status, String code, Object error) { + Object message = ((Map) error).get("message"); + return new Accepted(status, code, VideoPublicErrors.retryableOf(code), (String) message, null); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultException.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultException.java new file mode 100644 index 00000000..a7a007df --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultException.java @@ -0,0 +1,27 @@ +package repit.repit_api_server.domain.userdata.analysis.behavior; + +/** + * 영상 분석 결과 문서가 계약에 맞지 않는다. + * + *

메시지에는 어긋난 위치(JSON 경로)와 규칙 이름만 담는다. 받은 값은 넣지 않는다 — 이 메시지가 따로 남기는 기록의 + * detail과 로그에 그대로 실린다. + */ +public class VideoResultException extends RuntimeException { + + private final String path; + private final String rule; + + public VideoResultException(String path, String rule) { + super(path + ": " + rule); + this.path = path; + this.rule = rule; + } + + public String path() { + return path; + } + + public String rule() { + return rule; + } +} diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorContractTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorContractTest.java new file mode 100644 index 00000000..d099d8db --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorContractTest.java @@ -0,0 +1,387 @@ +package repit.repit_api_server.domain.userdata.analysis.behavior; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 행동 관찰 계약(behavior-v1)과 영상 분석 종료 문서를 검증기가 계약대로 가르는지. + * + *

fixture는 Web PR #189(Alpha-origin/Repit-web codex/behavior-feedback@29148cb)의 + * {@code docs/behavior-analysis/contracts}에서 그대로 가져온 합성 데이터다. behavior-*.json은 분석 서버 + * {@code tests/behavior/fixtures}와 같다. 모델 정확도의 증거가 아니라 계약의 증거다. + */ +class BehaviorContractTest { + + private static final JsonMapper JSON = JsonMapper.builder().build(); + private static final String VIDEO = "501"; + + // ---- 정상 fixture ---- + + @ParameterizedTest + @CsvSource({"behavior-ready, ready", "behavior-partial, partial", "behavior-zero-face, partial"}) + void 공유_행동_데이터는_통과하고_품질_판정이_같다(String name, String quality) { + BehaviorData data = BehaviorValidator.parse(fixture(name)); + + assertThat(BehaviorValidator.quality(data)).isEqualTo(quality); + // 내보내는 모양이 받은 문서와 같다. 웹에 그대로 내려간다. + assertThat(JsonValues.same(JSON.convertValue(data, Object.class), fixture(name))).isTrue(); + } + + @ParameterizedTest + @CsvSource({"callback-ready, ready", "callback-partial, partial", "callback-zero-face, partial", + "callback-unavailable, unavailable", "callback-unconfigured, unavailable", "callback-failed, failed"}) + void 공유_콜백은_통과한다(String name, String status) { + VideoResultDocument.Accepted accepted = VideoResultDocument.validate(fixture(name), VIDEO); + + assertThat(accepted.status()).isEqualTo(status); + assertThat(accepted.behavior() != null).isEqualTo(status.equals("ready") || status.equals("partial")); + } + + @Test + void 측정_불가_콜백의_오류는_공개_사전의_retryable로_남긴다() { + VideoResultDocument.Accepted unavailable = VideoResultDocument.validate(fixture("callback-unavailable"), VIDEO); + assertThat(unavailable.errorCode()).isEqualTo("BEHAVIOR_NOT_MEASURABLE"); + assertThat(unavailable.errorRetryable()).isFalse(); + + VideoResultDocument.Accepted failed = VideoResultDocument.validate(fixture("callback-failed"), VIDEO); + assertThat(failed.errorCode()).isEqualTo("INTERNAL_ERROR"); + assertThat(failed.errorRetryable()).isFalse(); + } + + // ---- 행동 데이터 부정 변형 ---- + + @ParameterizedTest + @ValueSource(strings = {"value", "bin", "event", "nullValue", "extra", "status", "warning", "duration", "durationTrue", + "durationText", "durationFraction", "nan", "truncatedText", "model", "schema", "reason", "eventSource", + "eventShort", "eventOverlap", "eventOrder", "truncatedNotFull", "summaryLong", "unknownWarning", + "duplicateWarning", "coverage", "sampleCount", "missingMetric", "timelineGap", "binOverflow", "emptySuccess", + "intervalFraction", "faceExceeded"}) + void 어긋난_행동_데이터는_받지_않는다(String mutation) { + Map raw = fixture("behavior-ready"); + mutateBehavior(raw, mutation); + + assertThatThrownBy(() -> BehaviorValidator.parse(raw)).isInstanceOf(VideoResultException.class); + } + + @Test + void 미측정_사유는_관찰_근거를_따른다() { + Map zeroFace = fixture("behavior-zero-face"); + metric(zeroFace, "headFacingRatio").put("unavailableReason", "BLENDSHAPES_UNAVAILABLE"); + assertThatThrownBy(() -> BehaviorValidator.parse(zeroFace)).hasMessageContaining("reason mismatch"); + + Map partial = fixture("behavior-partial"); + metric(partial, "shoulderLevelRatio").put("unavailableReason", "NO_SINGLE_FACE"); + assertThatThrownBy(() -> BehaviorValidator.parse(partial)).hasMessageContaining("reason mismatch"); + } + + /** 0%는 측정된 값이다. 측정 불가(null)와 섞이면 안 된다. */ + @Test + void 얼굴_0퍼센트는_측정된_0이고_나머지는_null이다() { + BehaviorData data = BehaviorValidator.parse(fixture("behavior-zero-face")); + + assertThat(data.metrics().get("faceVisibleRatio").value()).isEqualTo(0.0); + assertThat(data.metrics().get("headFacingRatio").value()).isNull(); + assertThat(data.metrics().get("headFacingRatio").unavailableReason()).isEqualTo("NO_SINGLE_FACE"); + } + + @Test + void 오류_메시지에는_받은_값을_싣지_않는다() { + Map raw = fixture("behavior-ready"); + raw.put("videoId", "secret-signed-url?X-Amz-Signature=abc"); + + assertThatCode(() -> BehaviorValidator.parse(raw)).doesNotThrowAnyException(); + raw.put("modelVersion", "secret-model"); + assertThatThrownBy(() -> BehaviorValidator.parse(raw)) + .isInstanceOf(VideoResultException.class) + .satisfies(e -> assertThat(e.getMessage()).doesNotContain("secret")); + } + + // ---- 콜백 포장 부정 변형 ---- + + @ParameterizedTest + @ValueSource(strings = {"readyWithoutData", "qualityMismatch", "retryableFlipped", "unknownCode", "retryableText", + "errorExtraField", "dataVideoMismatch", "resultVideoMismatch", "durationMismatch", "durationMissing", + "resultStatusMismatch", "analysisStatusMismatch", "failedWithResult", "failedWithoutError", + "readyWithError", "readyWithFileError", "fileErrorMismatch", "unavailableWithData", "nul", "schemaVersion", + "upperStatus", "missingAnalysisKey"}) + void 어긋난_종료_문서는_받지_않는다(String mutation) { + Map raw = mutateCallback(mutation); + + assertThatThrownBy(() -> VideoResultDocument.validate(raw, VIDEO)).isInstanceOf(VideoResultException.class); + } + + @Test + void 포장에_계약에_없는_필드가_더_있으면_받는다() { + Map raw = fixture("callback-ready"); + raw.put("traceId", "t-1"); + result(raw).put("extra", true); + + assertThatCode(() -> VideoResultDocument.validate(raw, VIDEO)).doesNotThrowAnyException(); + } + + // ---- 의미값 비교 ---- + + @Test + void 키_순서와_숫자_표기만_다른_문서는_같은_문서다() { + Map a = new LinkedHashMap<>(); + a.put("x", 1); + a.put("y", List.of(1.0, "a")); + Map b = new LinkedHashMap<>(); + b.put("y", List.of(1L, "a")); + b.put("x", 1.0); + + assertThat(JsonValues.same(a, b)).isTrue(); + assertThat(JsonValues.same(a, Map.of("x", 1, "y", List.of("a", 1)))).isFalse(); + assertThat(JsonValues.same(Map.of("x", true), Map.of("x", "true"))).isFalse(); + assertThat(JsonValues.same(Map.of("x", 1), Map.of("x", 1, "z", 2))).isFalse(); + } + + // ---- 변형 ---- + + private static void mutateBehavior(Map raw, String mutation) { + switch (mutation) { + case "value" -> metric(raw, "headFacingRatio").put("value", 0.99); + case "bin" -> pair(raw, 0, "faceVisibleRatio").put("numeratorMs", 10_001); + case "event" -> event(raw, 0).put("endMs", ((Number) event(raw, 0).get("endMs")).intValue() + 1000); + case "nullValue" -> metric(raw, "faceVisibleRatio").put("value", null); + case "extra" -> raw.put("image", "forbidden"); + case "status" -> metric(raw, "faceVisibleRatio").putAll(map("status", "UNAVAILABLE", "value", null, + "unavailableReason", "LOW_COVERAGE")); + case "warning" -> raw.put("warnings", new ArrayList<>(List.of("LOW_POSE_COVERAGE"))); + case "duration" -> raw.put("durationMs", 4000); + case "durationTrue" -> raw.put("durationMs", true); + case "durationText" -> raw.put("durationMs", "60000"); + case "durationFraction" -> raw.put("durationMs", 60000.5); + case "nan" -> metric(raw, "faceVisibleRatio").put("value", Double.NaN); + case "truncatedText" -> raw.put("eventsTruncated", "false"); + case "model" -> raw.put("modelVersion", "mp-face-pose-v2"); + case "schema" -> raw.put("schemaVersion", "behavior-v2"); + case "reason" -> metric(raw, "faceVisibleRatio").put("unavailableReason", "LOW_COVERAGE"); + case "eventSource" -> event(raw, 0).put("sourceMetric", "smileShapeRatio"); + case "eventShort" -> event(raw, 0).put("endMs", ((Number) event(raw, 0).get("startMs")).intValue() + 1999); + case "eventOverlap" -> { + Map copy = new LinkedHashMap<>(event(raw, 0)); + events(raw).add(1, copy); + } + case "eventOrder" -> { + List events = events(raw); + if (events.size() < 2) { + Map later = new LinkedHashMap<>(event(raw, 0)); + later.put("startMs", 40_000); + later.put("endMs", 42_000); + events.addFirst(later); + } else { + events.add(events.removeFirst()); + } + } + case "truncatedNotFull" -> { + raw.put("eventsTruncated", true); + List warnings = new ArrayList<>(warnings(raw)); + warnings.add("EVENTS_TRUNCATED"); + warnings.sort(null); + raw.put("warnings", warnings); + } + case "summaryLong" -> raw.put("summary", List.of("가".repeat(201))); + case "unknownWarning" -> raw.put("warnings", List.of("ATTENTION_LOW")); + case "duplicateWarning" -> raw.put("warnings", List.of("MULTIPLE_PEOPLE", "MULTIPLE_PEOPLE")); + case "coverage" -> metric(raw, "smileShapeRatio").put("coverageRatio", 0.5); + case "sampleCount" -> raw.put("sampledFrameCount", 301); + case "missingMetric" -> ((Map) raw.get("metrics")).remove("smileShapeRatio"); + case "timelineGap" -> bin(raw, 1).put("startMs", 10_001); + case "binOverflow" -> pair(raw, 0, "smileShapeRatio").put("denominatorMs", 10_200); + case "emptySuccess" -> { + for (String key : BehaviorPolicy.KEYS) { + metric(raw, key).putAll(map("status", "UNAVAILABLE", "value", null, "unavailableReason", "LOW_COVERAGE")); + } + } + case "intervalFraction" -> raw.put("sampleIntervalMs", 200.0); + case "faceExceeded" -> metric(raw, "faceVisibleRatio").put("numeratorMs", 1); + default -> throw new IllegalArgumentException(mutation); + } + } + + private static Map mutateCallback(String mutation) { + Map raw; + switch (mutation) { + case "readyWithoutData" -> { + raw = fixture("callback-ready"); + analysis(raw).put("data", null); + } + case "qualityMismatch" -> { + raw = fixture("callback-ready"); + setStatus(raw, "partial"); + } + case "retryableFlipped" -> { + raw = fixture("callback-unavailable"); + error(analysis(raw)).put("retryable", true); + } + case "unknownCode" -> { + raw = fixture("callback-unavailable"); + error(analysis(raw)).put("code", "MODEL_CRASHED"); + } + case "retryableText" -> { + raw = fixture("callback-unavailable"); + error(analysis(raw)).put("retryable", "false"); + } + case "errorExtraField" -> { + raw = fixture("callback-failed"); + error(raw).put("stack", "trace"); + } + case "dataVideoMismatch" -> { + raw = fixture("callback-ready"); + data(raw).put("videoId", "502"); + } + case "resultVideoMismatch" -> { + raw = fixture("callback-ready"); + result(raw).put("videoId", "502"); + data(raw).put("videoId", "502"); + } + case "durationMismatch" -> { + raw = fixture("callback-ready"); + result(raw).put("durationMs", 60_001); + } + case "durationMissing" -> { + raw = fixture("callback-partial"); + result(raw).put("durationMs", null); + } + case "resultStatusMismatch" -> { + raw = fixture("callback-ready"); + result(raw).put("status", "partial"); + } + case "analysisStatusMismatch" -> { + raw = fixture("callback-partial"); + analysis(raw).put("status", "ready"); + } + case "failedWithResult" -> { + raw = fixture("callback-failed"); + raw.put("result", fixture("callback-ready").get("result")); + } + case "failedWithoutError" -> { + raw = fixture("callback-failed"); + raw.put("error", null); + } + case "readyWithError" -> { + raw = fixture("callback-ready"); + analysis(raw).put("error", error(analysis(fixture("callback-unavailable")))); + } + case "readyWithFileError" -> { + raw = fixture("callback-ready"); + result(raw).put("error", error(analysis(fixture("callback-unavailable")))); + } + case "fileErrorMismatch" -> { + raw = fixture("callback-unavailable"); + result(raw).put("error", map("code", "VIDEO_DECODE_FAILED", "message", "x", "retryable", false)); + } + case "unavailableWithData" -> { + raw = fixture("callback-unavailable"); + analysis(raw).put("data", fixture("behavior-ready")); + } + case "nul" -> { + raw = fixture("callback-ready"); + data(raw).put("summary", List.of("a\u0000b")); + } + case "schemaVersion" -> { + raw = fixture("callback-ready"); + raw.put("schemaVersion", 1); + } + case "upperStatus" -> { + raw = fixture("callback-ready"); + setStatus(raw, "READY"); + } + case "missingAnalysisKey" -> { + raw = fixture("callback-unconfigured"); + analysis(raw).remove("data"); + } + default -> throw new IllegalArgumentException(mutation); + } + return raw; + } + + private static void setStatus(Map raw, String status) { + raw.put("status", status); + result(raw).put("status", status); + analysis(raw).put("status", status); + } + + // ---- 도구 ---- + + static Map fixture(String name) { + try (InputStream in = BehaviorContractTest.class.getResourceAsStream("/contracts/behavior/" + name + ".json")) { + return JSON.readValue(in, new TypeReference>() { + }); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @SuppressWarnings("unchecked") + private static Map obj(Object value) { + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List events(Map raw) { + return (List) raw.get("events"); + } + + @SuppressWarnings("unchecked") + private static List warnings(Map raw) { + return (List) raw.get("warnings"); + } + + private static Map metric(Map raw, String key) { + return obj(obj(raw.get("metrics")).get(key)); + } + + @SuppressWarnings("unchecked") + private static Map bin(Map raw, int index) { + return obj(((List) raw.get("timeline")).get(index)); + } + + private static Map pair(Map raw, int bin, String key) { + return obj(obj(bin(raw, bin).get("metrics")).get(key)); + } + + private static Map event(Map raw, int index) { + return obj(events(raw).get(index)); + } + + private static Map result(Map callback) { + return obj(callback.get("result")); + } + + private static Map analysis(Map callback) { + return obj(result(callback).get("analysis")); + } + + private static Map data(Map callback) { + return obj(analysis(callback).get("data")); + } + + private static Map error(Map holder) { + return obj(holder.get("error")); + } + + private static Map map(Object... pairs) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < pairs.length; i += 2) { + map.put((String) pairs[i], pairs[i + 1]); + } + return map; + } +} diff --git a/src/test/resources/contracts/behavior/api-analyzing-previous.json b/src/test/resources/contracts/behavior/api-analyzing-previous.json new file mode 100644 index 00000000..a59b0a97 --- /dev/null +++ b/src/test/resources/contracts/behavior/api-analyzing-previous.json @@ -0,0 +1,498 @@ +{ + "success": true, + "data": { + "status": "ANALYZING", + "result": { + "schemaVersion": "1", + "jobId": "job-001", + "requestId": "video-request-001", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "partial", + "result": { + "videoId": "501", + "durationMs": 60000, + "status": "partial", + "analysis": { + "status": "partial", + "data": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.95, + "numeratorMs": 57000, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "AVAILABLE", + "value": 0.8, + "numeratorMs": 43200, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "shoulderLevelRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "upperBodyStableRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "smileShapeRatio": { + "status": "AVAILABLE", + "value": 0.15, + "numeratorMs": 8100, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 7000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 5600, + "denominatorMs": 7000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1050, + "denominatorMs": 7000 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + } + ], + "events": [ + { + "type": "HEAD_TURNED", + "sourceMetric": "headFacingRatio", + "startMs": 10000, + "endMs": 12000 + }, + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 20000, + "endMs": 23000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 95.0%입니다.", + "머리가 카메라 정면 방향으로 추정된 구간은 방향을 판단할 수 있는 시간의 80.0%입니다.", + "미소와 비슷한 입꼬리 형태가 관찰된 구간은 측정 가능 시간의 15.0%입니다." + ], + "warnings": [ + "LOW_POSE_COVERAGE" + ] + }, + "error": null + }, + "error": null + }, + "error": null + }, + "canRetry": false, + "reasonCode": null, + "error": null, + "behavior": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.95, + "numeratorMs": 57000, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "AVAILABLE", + "value": 0.8, + "numeratorMs": 43200, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "shoulderLevelRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "upperBodyStableRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "smileShapeRatio": { + "status": "AVAILABLE", + "value": 0.15, + "numeratorMs": 8100, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 7000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 5600, + "denominatorMs": 7000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1050, + "denominatorMs": 7000 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + } + ], + "events": [ + { + "type": "HEAD_TURNED", + "sourceMetric": "headFacingRatio", + "startMs": 10000, + "endMs": 12000 + }, + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 20000, + "endMs": 23000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 95.0%입니다.", + "머리가 카메라 정면 방향으로 추정된 구간은 방향을 판단할 수 있는 시간의 80.0%입니다.", + "미소와 비슷한 입꼬리 형태가 관찰된 구간은 측정 가능 시간의 15.0%입니다." + ], + "warnings": [ + "LOW_POSE_COVERAGE" + ] + }, + "latestAnalysisId": "102", + "behaviorAnalysisId": "101", + "showingPreviousResult": true + } +} diff --git a/src/test/resources/contracts/behavior/api-disabled.json b/src/test/resources/contracts/behavior/api-disabled.json new file mode 100644 index 00000000..3ffc2cd0 --- /dev/null +++ b/src/test/resources/contracts/behavior/api-disabled.json @@ -0,0 +1,14 @@ +{ + "success": true, + "data": { + "status": "NONE", + "result": null, + "canRetry": false, + "reasonCode": "SERVICE_DISABLED", + "error": null, + "behavior": null, + "latestAnalysisId": null, + "behaviorAnalysisId": null, + "showingPreviousResult": false + } +} diff --git a/src/test/resources/contracts/behavior/api-failed-previous.json b/src/test/resources/contracts/behavior/api-failed-previous.json new file mode 100644 index 00000000..abdd240d --- /dev/null +++ b/src/test/resources/contracts/behavior/api-failed-previous.json @@ -0,0 +1,274 @@ +{ + "success": true, + "data": { + "status": "FAILED", + "result": { + "schemaVersion": "1", + "jobId": "job-002", + "requestId": "video-request-002", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "unavailable", + "result": { + "videoId": "501", + "durationMs": 60000, + "status": "unavailable", + "analysis": { + "status": "unavailable", + "data": null, + "error": { + "code": "PROCESSING_TIMEOUT", + "message": "영상 처리 시간이 제한을 넘었습니다.", + "retryable": true + } + }, + "error": null + }, + "error": null + }, + "canRetry": true, + "reasonCode": "PROCESSING_TIMEOUT", + "error": { + "code": "PROCESSING_TIMEOUT", + "message": "영상 처리 시간이 제한을 넘었습니다.", + "retryable": true + }, + "behavior": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.95, + "numeratorMs": 57000, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "AVAILABLE", + "value": 0.8, + "numeratorMs": 43200, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "shoulderLevelRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "upperBodyStableRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "smileShapeRatio": { + "status": "AVAILABLE", + "value": 0.15, + "numeratorMs": 8100, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 7000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 5600, + "denominatorMs": 7000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1050, + "denominatorMs": 7000 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + } + ], + "events": [ + { + "type": "HEAD_TURNED", + "sourceMetric": "headFacingRatio", + "startMs": 10000, + "endMs": 12000 + }, + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 20000, + "endMs": 23000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 95.0%입니다.", + "머리가 카메라 정면 방향으로 추정된 구간은 방향을 판단할 수 있는 시간의 80.0%입니다.", + "미소와 비슷한 입꼬리 형태가 관찰된 구간은 측정 가능 시간의 15.0%입니다." + ], + "warnings": [ + "LOW_POSE_COVERAGE" + ] + }, + "latestAnalysisId": "102", + "behaviorAnalysisId": "101", + "showingPreviousResult": true + } +} diff --git a/src/test/resources/contracts/behavior/api-no-recording.json b/src/test/resources/contracts/behavior/api-no-recording.json new file mode 100644 index 00000000..d9d133df --- /dev/null +++ b/src/test/resources/contracts/behavior/api-no-recording.json @@ -0,0 +1,14 @@ +{ + "success": true, + "data": { + "status": "NONE", + "result": null, + "canRetry": false, + "reasonCode": "NO_RECORDING", + "error": null, + "behavior": null, + "latestAnalysisId": null, + "behaviorAnalysisId": null, + "showingPreviousResult": false + } +} diff --git a/src/test/resources/contracts/behavior/api-partial.json b/src/test/resources/contracts/behavior/api-partial.json new file mode 100644 index 00000000..9d949fe9 --- /dev/null +++ b/src/test/resources/contracts/behavior/api-partial.json @@ -0,0 +1,498 @@ +{ + "success": true, + "data": { + "status": "PARTIAL", + "result": { + "schemaVersion": "1", + "jobId": "job-001", + "requestId": "video-request-001", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "partial", + "result": { + "videoId": "501", + "durationMs": 60000, + "status": "partial", + "analysis": { + "status": "partial", + "data": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.95, + "numeratorMs": 57000, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "AVAILABLE", + "value": 0.8, + "numeratorMs": 43200, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "shoulderLevelRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "upperBodyStableRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "smileShapeRatio": { + "status": "AVAILABLE", + "value": 0.15, + "numeratorMs": 8100, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 7000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 5600, + "denominatorMs": 7000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1050, + "denominatorMs": 7000 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + } + ], + "events": [ + { + "type": "HEAD_TURNED", + "sourceMetric": "headFacingRatio", + "startMs": 10000, + "endMs": 12000 + }, + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 20000, + "endMs": 23000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 95.0%입니다.", + "머리가 카메라 정면 방향으로 추정된 구간은 방향을 판단할 수 있는 시간의 80.0%입니다.", + "미소와 비슷한 입꼬리 형태가 관찰된 구간은 측정 가능 시간의 15.0%입니다." + ], + "warnings": [ + "LOW_POSE_COVERAGE" + ] + }, + "error": null + }, + "error": null + }, + "error": null + }, + "canRetry": true, + "reasonCode": null, + "error": null, + "behavior": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.95, + "numeratorMs": 57000, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "AVAILABLE", + "value": 0.8, + "numeratorMs": 43200, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "shoulderLevelRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "upperBodyStableRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "smileShapeRatio": { + "status": "AVAILABLE", + "value": 0.15, + "numeratorMs": 8100, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 7000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 5600, + "denominatorMs": 7000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1050, + "denominatorMs": 7000 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + } + ], + "events": [ + { + "type": "HEAD_TURNED", + "sourceMetric": "headFacingRatio", + "startMs": 10000, + "endMs": 12000 + }, + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 20000, + "endMs": 23000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 95.0%입니다.", + "머리가 카메라 정면 방향으로 추정된 구간은 방향을 판단할 수 있는 시간의 80.0%입니다.", + "미소와 비슷한 입꼬리 형태가 관찰된 구간은 측정 가능 시간의 15.0%입니다." + ], + "warnings": [ + "LOW_POSE_COVERAGE" + ] + }, + "latestAnalysisId": "101", + "behaviorAnalysisId": "101", + "showingPreviousResult": false + } +} diff --git a/src/test/resources/contracts/behavior/api-ready.json b/src/test/resources/contracts/behavior/api-ready.json new file mode 100644 index 00000000..117d7458 --- /dev/null +++ b/src/test/resources/contracts/behavior/api-ready.json @@ -0,0 +1,494 @@ +{ + "success": true, + "data": { + "status": "READY", + "result": { + "schemaVersion": "1", + "jobId": "job-001", + "requestId": "video-request-001", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "ready", + "result": { + "videoId": "501", + "durationMs": 60000, + "status": "ready", + "analysis": { + "status": "ready", + "data": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.95, + "numeratorMs": 57000, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "AVAILABLE", + "value": 0.8, + "numeratorMs": 43200, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "shoulderLevelRatio": { + "status": "AVAILABLE", + "value": 0.9, + "numeratorMs": 48600, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "upperBodyStableRatio": { + "status": "AVAILABLE", + "value": 0.9, + "numeratorMs": 47700, + "denominatorMs": 53000, + "coverageRatio": 0.883333, + "unavailableReason": null + }, + "smileShapeRatio": { + "status": "AVAILABLE", + "value": 0.15, + "numeratorMs": 8100, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 9000, + "denominatorMs": 10000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8820, + "denominatorMs": 9800 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 9000, + "denominatorMs": 10000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8820, + "denominatorMs": 9800 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 7000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 5600, + "denominatorMs": 7000 + }, + "shoulderLevelRatio": { + "numeratorMs": 6300, + "denominatorMs": 7000 + }, + "upperBodyStableRatio": { + "numeratorMs": 6120, + "denominatorMs": 6800 + }, + "smileShapeRatio": { + "numeratorMs": 1050, + "denominatorMs": 7000 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 7920, + "denominatorMs": 8800 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 7920, + "denominatorMs": 8800 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + } + ], + "events": [ + { + "type": "HEAD_TURNED", + "sourceMetric": "headFacingRatio", + "startMs": 10000, + "endMs": 12000 + }, + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 20000, + "endMs": 23000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 95.0%입니다.", + "머리가 카메라 정면 방향으로 추정된 구간은 방향을 판단할 수 있는 시간의 80.0%입니다.", + "어깨선이 수평 범위에 있던 구간은 어깨를 관찰할 수 있는 시간의 90.0%입니다." + ], + "warnings": [] + }, + "error": null + }, + "error": null + }, + "error": null + }, + "canRetry": false, + "reasonCode": null, + "error": null, + "behavior": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.95, + "numeratorMs": 57000, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "AVAILABLE", + "value": 0.8, + "numeratorMs": 43200, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "shoulderLevelRatio": { + "status": "AVAILABLE", + "value": 0.9, + "numeratorMs": 48600, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "upperBodyStableRatio": { + "status": "AVAILABLE", + "value": 0.9, + "numeratorMs": 47700, + "denominatorMs": 53000, + "coverageRatio": 0.883333, + "unavailableReason": null + }, + "smileShapeRatio": { + "status": "AVAILABLE", + "value": 0.15, + "numeratorMs": 8100, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 9000, + "denominatorMs": 10000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8820, + "denominatorMs": 9800 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 9000, + "denominatorMs": 10000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8820, + "denominatorMs": 9800 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 7000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 5600, + "denominatorMs": 7000 + }, + "shoulderLevelRatio": { + "numeratorMs": 6300, + "denominatorMs": 7000 + }, + "upperBodyStableRatio": { + "numeratorMs": 6120, + "denominatorMs": 6800 + }, + "smileShapeRatio": { + "numeratorMs": 1050, + "denominatorMs": 7000 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 7920, + "denominatorMs": 8800 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 7920, + "denominatorMs": 8800 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + } + ], + "events": [ + { + "type": "HEAD_TURNED", + "sourceMetric": "headFacingRatio", + "startMs": 10000, + "endMs": 12000 + }, + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 20000, + "endMs": 23000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 95.0%입니다.", + "머리가 카메라 정면 방향으로 추정된 구간은 방향을 판단할 수 있는 시간의 80.0%입니다.", + "어깨선이 수평 범위에 있던 구간은 어깨를 관찰할 수 있는 시간의 90.0%입니다." + ], + "warnings": [] + }, + "latestAnalysisId": "101", + "behaviorAnalysisId": "101", + "showingPreviousResult": false + } +} diff --git a/src/test/resources/contracts/behavior/api-unavailable.json b/src/test/resources/contracts/behavior/api-unavailable.json new file mode 100644 index 00000000..3ec085b6 --- /dev/null +++ b/src/test/resources/contracts/behavior/api-unavailable.json @@ -0,0 +1,42 @@ +{ + "success": true, + "data": { + "status": "FAILED", + "result": { + "schemaVersion": "1", + "jobId": "job-001", + "requestId": "video-request-001", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "unavailable", + "result": { + "videoId": "501", + "durationMs": 4000, + "status": "unavailable", + "analysis": { + "status": "unavailable", + "data": null, + "error": { + "code": "BEHAVIOR_NOT_MEASURABLE", + "message": "이 영상에서는 행동 지표를 측정할 수 있는 구간이 충분하지 않습니다.", + "retryable": false + } + }, + "error": null + }, + "error": null + }, + "canRetry": false, + "reasonCode": "BEHAVIOR_NOT_MEASURABLE", + "error": { + "code": "BEHAVIOR_NOT_MEASURABLE", + "message": "이 영상에서는 행동 지표를 측정할 수 있는 구간이 충분하지 않습니다.", + "retryable": false + }, + "behavior": null, + "latestAnalysisId": "101", + "behaviorAnalysisId": null, + "showingPreviousResult": false + } +} diff --git a/src/test/resources/contracts/behavior/api-waiting.json b/src/test/resources/contracts/behavior/api-waiting.json new file mode 100644 index 00000000..789a3048 --- /dev/null +++ b/src/test/resources/contracts/behavior/api-waiting.json @@ -0,0 +1,14 @@ +{ + "success": true, + "data": { + "status": "WAITING", + "result": null, + "canRetry": false, + "reasonCode": null, + "error": null, + "behavior": null, + "latestAnalysisId": null, + "behaviorAnalysisId": null, + "showingPreviousResult": false + } +} diff --git a/src/test/resources/contracts/behavior/api-zero-face.json b/src/test/resources/contracts/behavior/api-zero-face.json new file mode 100644 index 00000000..b0bc9d20 --- /dev/null +++ b/src/test/resources/contracts/behavior/api-zero-face.json @@ -0,0 +1,484 @@ +{ + "success": true, + "data": { + "status": "PARTIAL", + "result": { + "schemaVersion": "1", + "jobId": "job-001", + "requestId": "video-request-001", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "partial", + "result": { + "videoId": "501", + "durationMs": 60000, + "status": "partial", + "analysis": { + "status": "partial", + "data": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.0, + "numeratorMs": 0, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "shoulderLevelRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "upperBodyStableRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "smileShapeRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + } + ], + "events": [ + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 0, + "endMs": 60000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 0.0%입니다." + ], + "warnings": [ + "LOW_FACE_COVERAGE", + "LOW_POSE_COVERAGE" + ] + }, + "error": null + }, + "error": null + }, + "error": null + }, + "canRetry": true, + "reasonCode": null, + "error": null, + "behavior": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.0, + "numeratorMs": 0, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "shoulderLevelRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "upperBodyStableRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "smileShapeRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + } + ], + "events": [ + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 0, + "endMs": 60000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 0.0%입니다." + ], + "warnings": [ + "LOW_FACE_COVERAGE", + "LOW_POSE_COVERAGE" + ] + }, + "latestAnalysisId": "101", + "behaviorAnalysisId": "101", + "showingPreviousResult": false + } +} diff --git a/src/test/resources/contracts/behavior/behavior-partial.json b/src/test/resources/contracts/behavior/behavior-partial.json new file mode 100644 index 00000000..d779c02d --- /dev/null +++ b/src/test/resources/contracts/behavior/behavior-partial.json @@ -0,0 +1,233 @@ +{ + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.95, + "numeratorMs": 57000, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "AVAILABLE", + "value": 0.8, + "numeratorMs": 43200, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "shoulderLevelRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "upperBodyStableRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "smileShapeRatio": { + "status": "AVAILABLE", + "value": 0.15, + "numeratorMs": 8100, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 7000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 5600, + "denominatorMs": 7000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1050, + "denominatorMs": 7000 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + } + ], + "events": [ + { + "type": "HEAD_TURNED", + "sourceMetric": "headFacingRatio", + "startMs": 10000, + "endMs": 12000 + }, + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 20000, + "endMs": 23000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 95.0%입니다.", + "머리가 카메라 정면 방향으로 추정된 구간은 방향을 판단할 수 있는 시간의 80.0%입니다.", + "미소와 비슷한 입꼬리 형태가 관찰된 구간은 측정 가능 시간의 15.0%입니다." + ], + "warnings": [ + "LOW_POSE_COVERAGE" + ] +} diff --git a/src/test/resources/contracts/behavior/behavior-ready.json b/src/test/resources/contracts/behavior/behavior-ready.json new file mode 100644 index 00000000..77f9fccf --- /dev/null +++ b/src/test/resources/contracts/behavior/behavior-ready.json @@ -0,0 +1,231 @@ +{ + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.95, + "numeratorMs": 57000, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "AVAILABLE", + "value": 0.8, + "numeratorMs": 43200, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "shoulderLevelRatio": { + "status": "AVAILABLE", + "value": 0.9, + "numeratorMs": 48600, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "upperBodyStableRatio": { + "status": "AVAILABLE", + "value": 0.9, + "numeratorMs": 47700, + "denominatorMs": 53000, + "coverageRatio": 0.883333, + "unavailableReason": null + }, + "smileShapeRatio": { + "status": "AVAILABLE", + "value": 0.15, + "numeratorMs": 8100, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 9000, + "denominatorMs": 10000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8820, + "denominatorMs": 9800 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 9000, + "denominatorMs": 10000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8820, + "denominatorMs": 9800 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 7000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 5600, + "denominatorMs": 7000 + }, + "shoulderLevelRatio": { + "numeratorMs": 6300, + "denominatorMs": 7000 + }, + "upperBodyStableRatio": { + "numeratorMs": 6120, + "denominatorMs": 6800 + }, + "smileShapeRatio": { + "numeratorMs": 1050, + "denominatorMs": 7000 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 7920, + "denominatorMs": 8800 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 7920, + "denominatorMs": 8800 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + } + ], + "events": [ + { + "type": "HEAD_TURNED", + "sourceMetric": "headFacingRatio", + "startMs": 10000, + "endMs": 12000 + }, + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 20000, + "endMs": 23000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 95.0%입니다.", + "머리가 카메라 정면 방향으로 추정된 구간은 방향을 판단할 수 있는 시간의 80.0%입니다.", + "어깨선이 수평 범위에 있던 구간은 어깨를 관찰할 수 있는 시간의 90.0%입니다." + ], + "warnings": [] +} diff --git a/src/test/resources/contracts/behavior/behavior-zero-face.json b/src/test/resources/contracts/behavior/behavior-zero-face.json new file mode 100644 index 00000000..010aeff6 --- /dev/null +++ b/src/test/resources/contracts/behavior/behavior-zero-face.json @@ -0,0 +1,226 @@ +{ + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.0, + "numeratorMs": 0, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "shoulderLevelRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "upperBodyStableRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "smileShapeRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + } + ], + "events": [ + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 0, + "endMs": 60000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 0.0%입니다." + ], + "warnings": [ + "LOW_FACE_COVERAGE", + "LOW_POSE_COVERAGE" + ] +} diff --git a/src/test/resources/contracts/behavior/callback-failed.json b/src/test/resources/contracts/behavior/callback-failed.json new file mode 100644 index 00000000..7cff8346 --- /dev/null +++ b/src/test/resources/contracts/behavior/callback-failed.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": "1", + "jobId": "job-001", + "requestId": "video-request-001", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "failed", + "result": null, + "error": { + "code": "INTERNAL_ERROR", + "message": "영상 분석 서버 내부 오류가 발생했습니다.", + "retryable": false + } +} diff --git a/src/test/resources/contracts/behavior/callback-partial.json b/src/test/resources/contracts/behavior/callback-partial.json new file mode 100644 index 00000000..68f6785a --- /dev/null +++ b/src/test/resources/contracts/behavior/callback-partial.json @@ -0,0 +1,253 @@ +{ + "schemaVersion": "1", + "jobId": "job-001", + "requestId": "video-request-001", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "partial", + "result": { + "videoId": "501", + "durationMs": 60000, + "status": "partial", + "analysis": { + "status": "partial", + "data": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.95, + "numeratorMs": 57000, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "AVAILABLE", + "value": 0.8, + "numeratorMs": 43200, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "shoulderLevelRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "upperBodyStableRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "SHOULDERS_NOT_VISIBLE" + }, + "smileShapeRatio": { + "status": "AVAILABLE", + "value": 0.15, + "numeratorMs": 8100, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 7000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 5600, + "denominatorMs": 7000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1050, + "denominatorMs": 7000 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + } + ], + "events": [ + { + "type": "HEAD_TURNED", + "sourceMetric": "headFacingRatio", + "startMs": 10000, + "endMs": 12000 + }, + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 20000, + "endMs": 23000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 95.0%입니다.", + "머리가 카메라 정면 방향으로 추정된 구간은 방향을 판단할 수 있는 시간의 80.0%입니다.", + "미소와 비슷한 입꼬리 형태가 관찰된 구간은 측정 가능 시간의 15.0%입니다." + ], + "warnings": [ + "LOW_POSE_COVERAGE" + ] + }, + "error": null + }, + "error": null + }, + "error": null +} diff --git a/src/test/resources/contracts/behavior/callback-ready.json b/src/test/resources/contracts/behavior/callback-ready.json new file mode 100644 index 00000000..4d42d654 --- /dev/null +++ b/src/test/resources/contracts/behavior/callback-ready.json @@ -0,0 +1,251 @@ +{ + "schemaVersion": "1", + "jobId": "job-001", + "requestId": "video-request-001", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "ready", + "result": { + "videoId": "501", + "durationMs": 60000, + "status": "ready", + "analysis": { + "status": "ready", + "data": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.95, + "numeratorMs": 57000, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "AVAILABLE", + "value": 0.8, + "numeratorMs": 43200, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "shoulderLevelRatio": { + "status": "AVAILABLE", + "value": 0.9, + "numeratorMs": 48600, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + }, + "upperBodyStableRatio": { + "status": "AVAILABLE", + "value": 0.9, + "numeratorMs": 47700, + "denominatorMs": 53000, + "coverageRatio": 0.883333, + "unavailableReason": null + }, + "smileShapeRatio": { + "status": "AVAILABLE", + "value": 0.15, + "numeratorMs": 8100, + "denominatorMs": 54000, + "coverageRatio": 0.9, + "unavailableReason": null + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 9000, + "denominatorMs": 10000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8820, + "denominatorMs": 9800 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 8000, + "denominatorMs": 10000 + }, + "shoulderLevelRatio": { + "numeratorMs": 9000, + "denominatorMs": 10000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8820, + "denominatorMs": 9800 + }, + "smileShapeRatio": { + "numeratorMs": 1500, + "denominatorMs": 10000 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 7000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 5600, + "denominatorMs": 7000 + }, + "shoulderLevelRatio": { + "numeratorMs": 6300, + "denominatorMs": 7000 + }, + "upperBodyStableRatio": { + "numeratorMs": 6120, + "denominatorMs": 6800 + }, + "smileShapeRatio": { + "numeratorMs": 1050, + "denominatorMs": 7000 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 7920, + "denominatorMs": 8800 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 7920, + "denominatorMs": 8800 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 10000, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 7200, + "denominatorMs": 9000 + }, + "shoulderLevelRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "upperBodyStableRatio": { + "numeratorMs": 8100, + "denominatorMs": 9000 + }, + "smileShapeRatio": { + "numeratorMs": 1350, + "denominatorMs": 9000 + } + } + } + ], + "events": [ + { + "type": "HEAD_TURNED", + "sourceMetric": "headFacingRatio", + "startMs": 10000, + "endMs": 12000 + }, + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 20000, + "endMs": 23000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 95.0%입니다.", + "머리가 카메라 정면 방향으로 추정된 구간은 방향을 판단할 수 있는 시간의 80.0%입니다.", + "어깨선이 수평 범위에 있던 구간은 어깨를 관찰할 수 있는 시간의 90.0%입니다." + ], + "warnings": [] + }, + "error": null + }, + "error": null + }, + "error": null +} diff --git a/src/test/resources/contracts/behavior/callback-unavailable.json b/src/test/resources/contracts/behavior/callback-unavailable.json new file mode 100644 index 00000000..5c3b9bc1 --- /dev/null +++ b/src/test/resources/contracts/behavior/callback-unavailable.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": "1", + "jobId": "job-001", + "requestId": "video-request-001", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "unavailable", + "result": { + "videoId": "501", + "durationMs": 4000, + "status": "unavailable", + "analysis": { + "status": "unavailable", + "data": null, + "error": { + "code": "BEHAVIOR_NOT_MEASURABLE", + "message": "이 영상에서는 행동 지표를 측정할 수 있는 구간이 충분하지 않습니다.", + "retryable": false + } + }, + "error": null + }, + "error": null +} diff --git a/src/test/resources/contracts/behavior/callback-unconfigured.json b/src/test/resources/contracts/behavior/callback-unconfigured.json new file mode 100644 index 00000000..22289747 --- /dev/null +++ b/src/test/resources/contracts/behavior/callback-unconfigured.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": "1", + "jobId": "job-001", + "requestId": "video-request-001", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "unavailable", + "result": { + "videoId": "501", + "durationMs": 60000, + "status": "unavailable", + "analysis": { + "status": "unavailable", + "data": null, + "error": { + "code": "ANALYZER_NOT_CONFIGURED", + "message": "영상 행동 분석기가 아직 연결되지 않았습니다.", + "retryable": false + } + }, + "error": null + }, + "error": null +} diff --git a/src/test/resources/contracts/behavior/callback-zero-face.json b/src/test/resources/contracts/behavior/callback-zero-face.json new file mode 100644 index 00000000..89122a8a --- /dev/null +++ b/src/test/resources/contracts/behavior/callback-zero-face.json @@ -0,0 +1,246 @@ +{ + "schemaVersion": "1", + "jobId": "job-001", + "requestId": "video-request-001", + "sessionId": "session-42", + "interviewId": "42", + "userId": "7", + "status": "partial", + "result": { + "videoId": "501", + "durationMs": 60000, + "status": "partial", + "analysis": { + "status": "partial", + "data": { + "schemaVersion": "behavior-v1", + "modelVersion": "mp-face-pose-v1", + "thresholdsVersion": "behavior-policy-v1", + "videoId": "501", + "durationMs": 60000, + "sampleIntervalMs": 200, + "sampledFrameCount": 300, + "sampledDurationMs": 60000, + "metrics": { + "faceVisibleRatio": { + "status": "AVAILABLE", + "value": 0.0, + "numeratorMs": 0, + "denominatorMs": 60000, + "coverageRatio": 1.0, + "unavailableReason": null + }, + "headFacingRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "shoulderLevelRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "upperBodyStableRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + }, + "smileShapeRatio": { + "status": "UNAVAILABLE", + "value": null, + "numeratorMs": 0, + "denominatorMs": 0, + "coverageRatio": 0.0, + "unavailableReason": "NO_SINGLE_FACE" + } + }, + "timeline": [ + { + "startMs": 0, + "endMs": 10000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 10000, + "endMs": 20000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 20000, + "endMs": 30000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 30000, + "endMs": 40000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 40000, + "endMs": 50000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + }, + { + "startMs": 50000, + "endMs": 60000, + "metrics": { + "faceVisibleRatio": { + "numeratorMs": 0, + "denominatorMs": 10000 + }, + "headFacingRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "shoulderLevelRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "upperBodyStableRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + }, + "smileShapeRatio": { + "numeratorMs": 0, + "denominatorMs": 0 + } + } + } + ], + "events": [ + { + "type": "FACE_NOT_VISIBLE", + "sourceMetric": "faceVisibleRatio", + "startMs": 0, + "endMs": 60000 + } + ], + "eventsTruncated": false, + "summary": [ + "얼굴이 관찰된 구간은 판단 가능한 시간의 0.0%입니다." + ], + "warnings": [ + "LOW_FACE_COVERAGE", + "LOW_POSE_COVERAGE" + ] + }, + "error": null + }, + "error": null + }, + "error": null +} From f6bd95c3741cab5b4f51b5c27da9e5e7b95faed2 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Tue, 20 Oct 2026 21:30:03 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(analysis):=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EC=9E=90=EA=B2=A9=C2=B7OFF=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=C2=B7Web=20=ED=96=89=EB=8F=99=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=C2=B7=EA=B2=B0=EA=B3=BC=20=EB=A9=B1=EB=93=B1=EC=84=B1=EA=B3=BC?= =?UTF-8?q?=20=EA=B2=BD=EC=9F=81=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 요청·재전송·작업 조회 복구·자동/사용자 재분석·조회·늦은 콜백이 모두 COMPLETED 음성 면접만 통과한다. 중단·텍스트는 정책 사유로 닫고 진행 중은 기다린다. - GET/retry 응답에 reasonCode·error·behavior·latestAnalysisId·behaviorAnalysisId·showingPreviousResult를 더했다. 상태와 표시 결과를 따로 고른다. - sweep을 3단계로 나눠 꺼져 있어도 맡긴 요청의 재전송·조회는 계속한다. 꺼져 있으면 자동 재분석 대기로 보이지 않는다. - 콜백·작업 조회는 요청 행을 잠그고 같은 검증기를 쓴다. 같은 문서는 no-op, 상충 문서는 처음 것을 두고 따로 남기며, 결과 없이 닫은 행의 늦은 첫 결과는 받는다. - 요청 생성 경로는 면접 행 잠금 아래에서 자격·진행 중 요청·사용자 한도를 다시 본다. - 콜백 본문은 인증 뒤 UTF-8 1MiB까지만 읽고 넘으면 413이다. 영상 경로 본문은 로그에 싣지 않는다. Co-Authored-By: Claude Opus 5.5 --- .../controller/VideoAnalysisController.java | 42 +- .../dto/response/VideoAnalysisResponse.java | 46 +- .../repository/VideoAnalysisRepository.java | 22 + .../service/VideoAnalysisCallbackHandler.java | 262 +++++-- .../service/VideoAnalysisEligibility.java | 52 ++ .../service/VideoAnalysisService.java | 394 ++++++++-- .../repository/InterviewRepository.java | 9 + .../global/logging/RequestLoggingFilter.java | 23 +- src/main/resources/application.yaml | 15 +- .../VideoAnalysisControllerTest.java | 69 ++ .../service/VideoAnalysisPersistenceTest.java | 731 ++++++++++++------ .../service/VideoAnalysisServiceTest.java | 214 ++++- .../VideoAnalysisViewContractTest.java | 235 ++++++ .../logging/RequestLoggingFilterTest.java | 48 ++ 14 files changed, 1737 insertions(+), 425 deletions(-) create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisEligibility.java create mode 100644 src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisViewContractTest.java diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/controller/VideoAnalysisController.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/controller/VideoAnalysisController.java index 342a6517..d1196634 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/controller/VideoAnalysisController.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/controller/VideoAnalysisController.java @@ -1,5 +1,6 @@ package repit.repit_api_server.domain.userdata.analysis.controller; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -7,7 +8,6 @@ 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.response.VideoAnalysisResponse; @@ -15,6 +15,13 @@ import repit.repit_api_server.domain.userdata.analysis.service.VideoAnalysisService; import repit.repit_api_server.global.auth.AuthUser; import repit.repit_api_server.global.common.ApiResponse; +import repit.repit_api_server.global.exception.BusinessException; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; @RestController @RequiredArgsConstructor @@ -26,14 +33,36 @@ public class VideoAnalysisController { /** * 분석 서버 전용 콜백. 본문을 원문 그대로 받는다 — 모양이 어긋나도 400으로 버리지 않고 따로 남기기 위해서다. * 검증을 통과하지 못한 원문도 남기는 데 성공하면 200으로 답한다. + * + *

본문은 내부 인증(인터셉터)을 통과한 뒤에야 여기서 읽는다. UTF-8 1MiB를 넘으면 읽다 말고 413으로 끊고 아무것도 남기지 + * 않는다 — 넘는 원문을 메모리에 다 올리거나 DB에 쌓지 않는다. 길이를 밝히지 않은 본문도 상한까지만 읽는다. */ @PostMapping("/api/analyses/video/callback") - public ResponseEntity callback(@RequestBody(required = false) String body) { - callbackHandler.handle(body); + public ResponseEntity callback(HttpServletRequest request) throws IOException { + int limit = VideoAnalysisCallbackHandler.MAX_BODY_BYTES; + if (request.getContentLengthLong() > limit) { + throw tooLarge(); + } + byte[] bytes = request.getInputStream().readNBytes(limit + 1); + if (bytes.length > limit) { + throw tooLarge(); + } + String body; + try { + body = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException e) { + callbackHandler.keepUndecodable(new String(bytes, StandardCharsets.UTF_8)); + return ResponseEntity.ok().build(); + } + callbackHandler.handle(body.isEmpty() ? null : body); return ResponseEntity.ok().build(); } - /** 면접 화면 녹화의 분석 상태와 보여 줄 결과. */ + /** 면접 화면 녹화의 분석 상태와 보여 줄 행동 관찰 결과. 정상 완료되지 않은 면접은 409다. */ @GetMapping("/api/interviews/{interviewId}/video-analysis") public ApiResponse get(@AuthenticationPrincipal AuthUser authUser, @PathVariable Long interviewId) { @@ -47,4 +76,9 @@ public ApiResponse retry(@AuthenticationPrincipal AuthUse @PathVariable Long interviewId) { return ApiResponse.success(videoAnalysisService.retry(authUser.id(), interviewId)); } + + private static BusinessException tooLarge() { + return new BusinessException("콜백 본문은 " + VideoAnalysisCallbackHandler.MAX_BODY_BYTES + "바이트까지 받습니다.", + HttpStatus.CONTENT_TOO_LARGE); + } } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/dto/response/VideoAnalysisResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/dto/response/VideoAnalysisResponse.java index de0463bd..49e68dee 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/dto/response/VideoAnalysisResponse.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/dto/response/VideoAnalysisResponse.java @@ -1,24 +1,58 @@ package repit.repit_api_server.domain.userdata.analysis.dto.response; +import com.fasterxml.jackson.annotation.JsonInclude; +import repit.repit_api_server.domain.userdata.analysis.behavior.BehaviorData; +import repit.repit_api_server.domain.userdata.analysis.behavior.VideoPublicError; + import java.util.Map; /** - * 웹에 내려주는 면접 영상 분석 상태. 웹은 status만 보고 화면을 그린다. + * 웹에 내려주는 면접 영상 분석 상태와 행동 관찰 결과. + * + *

상태(status)는 최종 영상의 가장 최근 요청을 따르고, 행동 데이터(behavior)는 같은 영상의 요청 가운데 검증을 통과한 가장 + * 최근 ready/partial 결과를 따로 고른다. 재분석 중이거나 최근 요청이 실패해도 직전 결과를 계속 보여 줄 수 있게 하기 위해서다. + * 두 값이 같은 요청에서 왔다고 가정하지 않는다 — 어느 요청인지는 latestAnalysisId·behaviorAnalysisId로 밝힌다. 다른 영상의 + * 결과는 섞지 않는다. + * + *

값이 없는 필드도 키를 빼지 않고 null로 보낸다. 웹은 키가 없으면 행동 데이터를 모르는 옛 API로 본다. * - * @param status 아래 {@link Status} - * @param result 보여 줄 결과(최종 영상의 가장 최근 완료 결과). 재분석 중이면 직전 결과가 실린다. 없으면 null - * @param canRetry 지금 재분석을 요청할 수 있는지 + * @param status 아래 {@link Status} + * @param result 하위 호환용. 같은 영상의 가장 최근 완료(ready/partial/unavailable) 콜백 원문. 새 화면은 쓰지 않는다 + * @param canRetry 지금 재분석을 요청할 수 있는지. PARTIAL·FAILED에서 정책·원본·한도를 모두 만족할 때만 true + * @param reasonCode 상태의 공개 사유. NONE의 이유, FAILED의 공개 오류 코드, 읽을 수 없는 옛 결과 + * (RESULT_SCHEMA_UNSUPPORTED). 없으면 null + * @param error status가 FAILED일 때 최근 요청의 공개 오류. 아니면 null + * @param behavior 검증한 행동 데이터. 없으면 null + * @param latestAnalysisId 최신 요청 id. 요청이 없으면 null + * @param behaviorAnalysisId behavior를 만든 요청 id. behavior가 없으면 null + * @param showingPreviousResult behavior가 최신 요청이 아니라 같은 영상의 이전 요청 결과인지 */ -public record VideoAnalysisResponse(Status status, Map result, boolean canRetry) { +@JsonInclude(JsonInclude.Include.ALWAYS) +public record VideoAnalysisResponse( + Status status, + Map result, + boolean canRetry, + String reasonCode, + VideoPublicError error, + BehaviorData behavior, + String latestAnalysisId, + String behaviorAnalysisId, + boolean showingPreviousResult) { + + /** 요청이 없어 보여 줄 것이 없는 상태. */ + public static VideoAnalysisResponse empty(Status status, String reasonCode) { + return new VideoAnalysisResponse(status, null, false, reasonCode, null, null, null, null, false); + } public enum Status { - /** 면접 화면 녹화가 없거나, 보관 기간이 지났거나, 영상 분석을 켜기 전에 녹화해 분석하지 않는다. */ + /** 보여 줄 분석이 없다. 이유는 reasonCode(텍스트 면접, 녹화 없음, 켜기 전 녹화, 보관 기간 지남, 기능 꺼짐). */ NONE, /** 영상은 있고 아직 요청 전이다. */ WAITING, /** 분석 중이다. 자동 재분석을 기다리는 중도 여기에 든다. */ ANALYZING, READY, + /** 일부 지표만 측정됐다. 진행 중이 아니라 끝난 상태다. */ PARTIAL, /** 결과 없이 끝났다. 재분석할 수 있는지는 canRetry로 본다. */ FAILED diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/repository/VideoAnalysisRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/repository/VideoAnalysisRepository.java index c4bc7790..7c37e5ef 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/repository/VideoAnalysisRepository.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/repository/VideoAnalysisRepository.java @@ -1,7 +1,9 @@ package repit.repit_api_server.domain.userdata.analysis.repository; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; @@ -22,6 +24,14 @@ public interface VideoAnalysisRepository extends JpaRepository findByRequestId(String requestId); + /** + * 결과를 반영할 요청 행을 잠그고 읽는다. 같은 결과가 콜백과 작업 조회로 동시에 오거나 서로 다른 종료 문서가 겹쳐 와도 + * 한쪽이 끝날 때까지 다른 쪽이 기다렸다가, 먼저 저장된 문서를 보고 중복인지 상충인지 가린다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select v from VideoAnalysisEntity v where v.requestId = :requestId") + Optional findLockedByRequestId(String requestId); + Optional findByJobId(String jobId); List findAllByRecordingIdOrderByAnalysisIdAsc(Long recordingId); @@ -31,10 +41,17 @@ public interface VideoAnalysisRepository extends JpaRepository원본 보관 기간이 거의 끝난 영상은 보내지 않는다. 분석 서버가 내려받기 전에 지워진다. + * + *

정상 완료(COMPLETED)로 저장된 면접의 영상만 고른다. 텍스트 면접은 행동 분석 대상이 아니다. 진행 중인 면접의 영상은 + * 고르지 않고 남겨 둔다 — 면접이 완료로 확정되면 그때 고른다. 중단된 면접은 영영 고르지 않는다. */ @Query("select r from InterviewRecordingEntity r " + "where r.kind = repit.repit_api_server.domain.userdata.recording.entity.enums.RecordingKind.FULL_INTERVIEW " + "and r.createdAt < :quietBefore and r.createdAt > :uploadedAfter " + + "and exists (select 1 from InterviewEntity i where i.interviewId = r.interviewId " + + " and i.status = repit.repit_api_server.domain.userdata.interview.entity.enums.Status.COMPLETED " + + " and (i.answerMode is null " + + " or i.answerMode <> repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode.TEXT)) " + "and r.recordingId = (select max(r2.recordingId) from InterviewRecordingEntity r2 " + " where r2.interviewId = r.interviewId " + " and r2.kind = repit.repit_api_server.domain.userdata.recording.entity.enums.RecordingKind.FULL_INTERVIEW) " @@ -56,6 +73,7 @@ public interface VideoAnalysisRepository extends JpaRepository그 영상의 가장 최근 요청이다. 이미 다음 요청이 있거나 사용자가 새 묶음을 열었으면 잇지 않는다. *

  • 그 면접의 최종 영상이다. 새 영상이 올라왔으면 그 영상이 따로 요청된다.
  • *
  • 묶음의 작업 수가 한도 아래다. 새 요청 id마다 한도를 새로 세지 않는다.
  • + *
  • 정상 완료로 저장된 음성 면접이다. 중단·진행 중·텍스트 면접은 잇지 않는다.
  • * */ @Query("select v from VideoAnalysisEntity v " @@ -67,6 +85,10 @@ public interface VideoAnalysisRepository extends JpaRepository repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode.TEXT)) " + "order by v.analysisId") List findReanalysisCandidates(LocalDateTime failedBefore, long maxJobsPerChain, Limit limit); diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisCallbackHandler.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisCallbackHandler.java index 02ba99c4..2b39930a 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisCallbackHandler.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisCallbackHandler.java @@ -5,15 +5,20 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import repit.repit_api_server.domain.userdata.analysis.behavior.JsonValues; +import repit.repit_api_server.domain.userdata.analysis.behavior.VideoResultDocument; +import repit.repit_api_server.domain.userdata.analysis.behavior.VideoResultException; import repit.repit_api_server.domain.userdata.analysis.entity.UnmatchedCallbackEntity; import repit.repit_api_server.domain.userdata.analysis.entity.UnmatchedCallbackEntity.Reason; import repit.repit_api_server.domain.userdata.analysis.entity.VideoAnalysisEntity; import repit.repit_api_server.domain.userdata.analysis.entity.enums.VideoAnalysisStatus; import repit.repit_api_server.domain.userdata.analysis.repository.UnmatchedCallbackRepository; import repit.repit_api_server.domain.userdata.analysis.repository.VideoAnalysisRepository; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; import tools.jackson.core.JacksonException; import tools.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Objects; @@ -26,6 +31,17 @@ * 필수 값이 빠졌으면 원문을 {@code unmatched_callback}에 따로 남기고 행은 건드리지 않는다. 잘못 이어 붙인 * 결과는 남의 면접에 보이고, 되돌릴 단서도 남지 않는다. * + *

    그다음 문서 전체를 {@link VideoResultDocument}로 검증한다 — 포장 상태의 일치, 공개 오류와 retryable, 행동 데이터의 + * 타입·의미, 영상·길이 묶음. 콜백과 작업 조회 복구가 같은 검증을 거친다. 통과하지 못한 문서도 원문을 따로 남긴다. + * + *

    면접이 결과를 받을 자격이 있는지는 저장된 면접 상태로 본다. 중단·텍스트 면접의 결과는 반영하지 않고 기다리던 요청을 + * 정책 사유로 닫는다(분석 서버에는 정상 수신으로 답한다). 아직 진행 중인 면접이면 결과를 반영하지도 닫지도 않는다 — 완료되면 + * 작업 조회로 다시 받는다. + * + *

    요청 행을 잠그고 반영한다. 같은 요청에 이미 저장한 종료 문서가 있으면 의미가 같은 문서(키 순서만 다른 것 포함)는 그대로 + * 두고, 다른 문서는 처음 것을 유지하며 따로 남긴다 — ready가 늦게 온 failed로 바뀌지 않는다. 이 서버가 결과 없이 닫은 행 + * (접수 미확인·처리 상한 초과)은 원격 종료 문서가 없으므로 늦게 온 첫 결과를 받는다. + * *

    따로 남기는 데 성공하면 예외 없이 끝나 2xx로 답한다. 4xx로 답하면 분석 서버가 재전송을 멈추고, 같은 * 원문을 계속 받아 봐야 다시 반영되지 않는다. 저장 자체가 실패하면 예외가 올라가 5xx가 되고 분석 서버가 다시 보낸다. */ @@ -36,118 +52,209 @@ public class VideoAnalysisCallbackHandler { private static final Logger log = LoggerFactory.getLogger(VideoAnalysisCallbackHandler.class); static final String KIND = "VIDEO"; - // 이 서버가 읽을 줄 아는 결과 스키마. 추가된 선택 필드는 원문과 함께 저장될 뿐이다. - static final String SCHEMA_VERSION = "1"; - private static final List REQUIRED = - List.of("jobId", "requestId", "sessionId", "interviewId", "userId", "status", "schemaVersion"); + /** 결과 본문 상한(UTF-8 바이트). 분석 서버의 결과 문서 상한과 같다. 넘는 본문은 원문을 남기지 않는다. */ + public static final int MAX_BODY_BYTES = 1024 * 1024; + private static final List ID_FIELDS = List.of("jobId", "requestId", "sessionId", "interviewId", "userId"); + // unmatched_callback의 request_id·job_id 컬럼 길이. 넘는 값을 그대로 넣으면 따로 남기는 저장까지 실패한다. + private static final int MAX_ID_COLUMN = 255; + + /** 결과 하나를 받아 어떻게 처리했는지. */ + public enum Outcome { + /** 요청 행에 결과를 저장했다. */ + APPLIED, + /** 이미 저장한 것과 의미가 같은 문서라 아무것도 바꾸지 않았다. */ + DUPLICATE, + /** 이미 저장한 것과 다른 종료 문서다. 처음 것을 두고 원문을 따로 남겼다. */ + CONFLICT, + /** 식별자·형식·계약이 맞지 않아 원문을 따로 남겼다. */ + INVALID, + /** 중단·텍스트 면접이라 반영하지 않았다. 기다리던 요청이면 정책 사유로 닫았다. */ + POLICY_CLOSED, + /** 면접이 아직 끝나지 않아 반영도 닫지도 않았다. */ + POLICY_HELD + } private final VideoAnalysisRepository analysisRepository; private final UnmatchedCallbackRepository unmatchedRepository; + private final InterviewRepository interviewRepository; private final ObjectMapper objectMapper; - /** 분석 서버가 보낸 콜백 원문. */ + /** 분석 서버가 보낸 콜백 원문. 컨트롤러가 인증과 크기 상한을 거친 뒤 UTF-8로 읽어 넘긴다. */ @Transactional - public void handle(String rawBody) { + public Outcome handle(String rawBody) { String raw = Objects.requireNonNullElse(rawBody, ""); Map body = parse(raw); if (body == null) { keepAside(Reason.INVALID_BODY, null, null, "JSON 객체가 아닙니다.", raw); - return; + return Outcome.INVALID; } - apply(body, raw, null); + return apply(body, raw, null); + } + + /** UTF-8로 읽을 수 없는 콜백 본문. 깨진 바이트를 대체 문자로 바꾼 원문만 따로 남긴다. */ + @Transactional + public void keepUndecodable(String lossyBody) { + keepAside(Reason.INVALID_BODY, null, null, "UTF-8로 읽을 수 없는 본문입니다.", lossyBody); } /** * 작업 조회로 받은 결과. 콜백과 같은 검증을 거치고, 조회한 그 요청의 결과인지까지 본다. - * - * @return 정상 결과로 반영했으면 true */ @Transactional - public boolean applyRecovered(Long analysisId, Map body) { + public Outcome applyRecovered(Long analysisId, Map body) { if (body == null) { - keepAside(Reason.INVALID_BODY, null, null, "작업 조회가 completed인데 결과가 비어 있습니다. analysisId=" + analysisId, ""); - return false; + keepAside(Reason.INVALID_BODY, null, null, "작업 조회가 끝났다는데 결과가 비어 있습니다. analysisId=" + analysisId, ""); + return Outcome.INVALID; } - return apply(body, toJson(body), analysisId); + String raw = toJson(body); + if (raw.getBytes(StandardCharsets.UTF_8).length > MAX_BODY_BYTES) { + // 상한을 넘는 원문은 DB에 남기지 않는다. 무엇이 왔는지는 식별자와 크기로만 적는다. + keepAside(Reason.INVALID_BODY, idText(body, "requestId"), idText(body, "jobId"), + "작업 조회 결과가 " + MAX_BODY_BYTES + "바이트를 넘어 반영하지 않았습니다. analysisId=" + analysisId, ""); + return Outcome.INVALID; + } + return apply(body, raw, analysisId); } - private boolean apply(Map body, String raw, Long expectedAnalysisId) { - String requestId = text(body, "requestId"); - String jobId = text(body, "jobId"); + private Outcome apply(Map body, String raw, Long expectedAnalysisId) { + String requestId = idText(body, "requestId"); + String jobId = idText(body, "jobId"); - String invalid = invalidField(body); + // NUL은 PostgreSQL이 받지 못한다. 식별자에 섞여 오면 행을 찾는 쿼리부터 실패해 같은 본문이 끝없이 5xx로 되돌아온다. + if (JsonValues.containsNul(body)) { + keepAside(Reason.INVALID_BODY, requestId, jobId, "문자열에 NUL 문자가 있습니다.", raw); + return Outcome.INVALID; + } + String invalid = invalidEnvelope(body); if (invalid != null) { keepAside(Reason.INVALID_BODY, requestId, jobId, invalid, raw); - return false; + return Outcome.INVALID; } - VideoAnalysisEntity analysis = analysisRepository.findByRequestId(requestId).orElse(null); + VideoAnalysisEntity analysis = analysisRepository.findLockedByRequestId(requestId).orElse(null); if (analysis == null) { keepAside(Reason.UNKNOWN_REQUEST, requestId, jobId, "이 서버가 보낸 적 없는 요청 id입니다.", raw); - return false; + return Outcome.INVALID; } String mismatch = mismatch(analysis, body, jobId, expectedAnalysisId); if (mismatch != null) { keepAside(Reason.ID_MISMATCH, requestId, jobId, mismatch + " analysisId=" + analysis.getAnalysisId(), raw); - return false; + return Outcome.INVALID; + } + + VideoAnalysisEligibility.Verdict verdict = VideoAnalysisEligibility.of( + interviewRepository.findById(analysis.getInterviewId()).orElse(null)); + if (verdict.kind() == VideoAnalysisEligibility.Kind.HOLD) { + log.info("면접이 아직 끝나지 않아 영상 분석 결과를 반영하지 않고 둡니다. 완료되면 작업 조회로 다시 받습니다. " + + "analysisId={}, requestId={}", analysis.getAnalysisId(), requestId); + return Outcome.POLICY_HELD; + } + if (verdict.kind() == VideoAnalysisEligibility.Kind.SKIP) { + closeForPolicy(analysis, verdict); + return Outcome.POLICY_CLOSED; + } + + VideoResultDocument.Accepted accepted; + try { + accepted = VideoResultDocument.validate(body, String.valueOf(analysis.getRecordingId())); + } catch (VideoResultException e) { + keepAside(Reason.INVALID_BODY, requestId, jobId, + "결과 문서가 계약에 맞지 않습니다. " + e.getMessage() + " analysisId=" + analysis.getAnalysisId(), raw); + return Outcome.INVALID; + } + + if (analysis.getResult() != null) { + if (JsonValues.same(analysis.getResult(), body)) { + log.info("이미 저장한 영상 분석 결과와 같은 문서라 그대로 둡니다. analysisId={}, requestId={}", + analysis.getAnalysisId(), requestId); + return Outcome.DUPLICATE; + } + keepAside(Reason.INVALID_BODY, requestId, jobId, + "이미 저장한 종료 문서와 다른 문서입니다. 처음 저장한 결과를 유지합니다. analysisId=" + analysis.getAnalysisId(), raw); + return Outcome.CONFLICT; } - VideoAnalysisStatus status = VideoAnalysisStatus.fromResult(text(body, "status")); - Map error = error(body); + // 원격 종료 문서가 아직 없는 행이다. 기다리는 중(PENDING)이거나, 이 서버가 결과 없이 닫았다(FAILED). + VideoAnalysisStatus previous = analysis.getStatus(); + VideoAnalysisStatus status = VideoAnalysisStatus.fromResult(accepted.status()); analysis.setJobId(jobId); analysis.setStatus(status); - // failed에는 보여 줄 결과 문서가 없다. 비워 두어야 화면이 직전 완료 결과를 계속 보여 준다(작업 조회로 닫을 때와 같다). - analysis.setResult(status == VideoAnalysisStatus.FAILED ? null : body); - analysis.setErrorCode(error == null ? null : textOf(error.get("code"))); - analysis.setErrorRetryable(error != null && error.get("retryable") instanceof Boolean retryable ? retryable : null); - analysis.setErrorMessage(error == null ? null : textOf(error.get("message"))); + // failed 문서도 원문을 남긴다. 결과 없이 이 서버가 닫은 행과 분석 서버가 끝낸 행을 가르는 단서다. 화면의 호환용 + // result에는 완료(ready/partial/unavailable) 문서만 쓴다. + analysis.setResult(body); + analysis.setErrorCode(accepted.errorCode()); + analysis.setErrorRetryable(accepted.errorRetryable()); + analysis.setErrorMessage(accepted.errorMessage()); analysis.setNextCheckAt(null); analysisRepository.save(analysis); + if (previous != VideoAnalysisStatus.PENDING) { + log.warn("결과 없이 닫았던 영상 분석 요청에 늦게 온 첫 결과를 저장했습니다. analysisId={}, requestId={}, " + + "닫을 때 사유={}, status={}", analysis.getAnalysisId(), requestId, previous, status); + } log.info("영상 분석 결과를 저장했습니다. analysisId={}, requestId={}, jobId={}, status={}", analysis.getAnalysisId(), requestId, jobId, status); - return true; + return Outcome.APPLIED; + } + + /** 결과를 받을 수 없는 면접이다. 기다리던 요청이면 정책 사유로 닫고, 이미 끝난 행은 건드리지 않는다. */ + private void closeForPolicy(VideoAnalysisEntity analysis, VideoAnalysisEligibility.Verdict verdict) { + if (analysis.getStatus() == VideoAnalysisStatus.PENDING) { + analysis.setStatus(VideoAnalysisStatus.FAILED); + analysis.setErrorCode(verdict.code()); + analysis.setErrorRetryable(false); + analysis.setErrorMessage(verdict.message()); + analysis.setNextCheckAt(null); + analysisRepository.save(analysis); + } + log.info("분석 대상이 아닌 면접이라 영상 분석 결과를 반영하지 않습니다. analysisId={}, 사유={}", + analysis.getAnalysisId(), verdict.code()); + } + + /** 식별자는 공백뿐이 아닌 문자열이어야 한다. 숫자로 온 id는 계약 위반이다. */ + private static String invalidField(Map body, String field) { + Object value = body.get(field); + return value instanceof String text && !text.isBlank() ? null : "필수 필드 " + field + "가 없거나 문자열이 아닙니다."; } - /** 필수 필드·상태값·스키마 버전 가운데 맞지 않는 것. 모두 맞으면 null. */ - private static String invalidField(Map body) { - for (String field : REQUIRED) { - String value = text(body, field); - if (value == null || value.isBlank()) { - return "필수 필드 " + field + "가 없습니다."; + /** 식별자·스키마 버전·상태값 가운데 맞지 않는 것. 모두 맞으면 null. */ + private static String invalidEnvelope(Map body) { + for (String field : ID_FIELDS) { + String invalid = invalidField(body, field); + if (invalid != null) { + return invalid; } } - VideoAnalysisStatus status = VideoAnalysisStatus.fromResult(text(body, "status")); - if (status == null) { - return "알 수 없는 결과 상태입니다: " + text(body, "status"); + if (!VideoResultDocument.SCHEMA_VERSION.equals(body.get("schemaVersion"))) { + return "지원하지 않는 스키마 버전입니다: " + clip(String.valueOf(body.get("schemaVersion"))); + } + Object status = body.get("status"); + if (!(status instanceof String) || !VideoResultDocument.STATUSES.contains(status)) { + return "알 수 없는 결과 상태입니다: " + clip(String.valueOf(status)); } - if (status != VideoAnalysisStatus.FAILED) { + if (!"failed".equals(status)) { String videoId = videoId(body); if (videoId == null || videoId.isBlank()) { - return "필수 필드 result.videoId가 없습니다."; + return "필수 필드 result.videoId가 없거나 문자열이 아닙니다."; } } - if (!SCHEMA_VERSION.equals(text(body, "schemaVersion"))) { - return "지원하지 않는 스키마 버전입니다: " + text(body, "schemaVersion"); - } return null; } - /** 이 요청의 결과가 아닌 이유. 맞으면 null. */ + /** 이 요청의 결과가 아닌 이유. 맞으면 null. 보낸 값과 글자 그대로 같아야 한다. */ private String mismatch(VideoAnalysisEntity analysis, Map body, String jobId, Long expectedAnalysisId) { if (expectedAnalysisId != null && !expectedAnalysisId.equals(analysis.getAnalysisId())) { return "조회한 요청(" + expectedAnalysisId + ")과 결과의 요청이 다릅니다."; } - if (!Objects.equals(analysis.getSessionId(), text(body, "sessionId"))) { + if (!Objects.equals(analysis.getSessionId(), body.get("sessionId"))) { return "세션이 다릅니다."; } - if (!String.valueOf(analysis.getInterviewId()).equals(text(body, "interviewId"))) { + if (!String.valueOf(analysis.getInterviewId()).equals(body.get("interviewId"))) { return "면접이 다릅니다."; } - if (!String.valueOf(analysis.getUserId()).equals(text(body, "userId"))) { + if (!String.valueOf(analysis.getUserId()).equals(body.get("userId"))) { return "사용자가 다릅니다."; } - if (VideoAnalysisStatus.fromResult(text(body, "status")) != VideoAnalysisStatus.FAILED - && !String.valueOf(analysis.getRecordingId()).equals(videoId(body))) { + if (!"failed".equals(body.get("status")) && !String.valueOf(analysis.getRecordingId()).equals(videoId(body))) { return "영상이 다릅니다."; } if (analysis.getJobId() != null && !analysis.getJobId().equals(jobId)) { @@ -164,13 +271,14 @@ private void keepAside(Reason reason, String requestId, String jobId, String det unmatchedRepository.save(UnmatchedCallbackEntity.builder() .kind(KIND) .reason(reason) - .requestId(requestId) - .jobId(jobId) - .detail(detail) - .payload(raw) + .requestId(clipId(requestId)) + .jobId(clipId(jobId)) + .detail(withoutNul(detail)) + // PostgreSQL text에는 NUL을 넣을 수 없다. 남기지 못하면 같은 본문이 끝없이 5xx로 되돌아온다. + .payload(withoutNul(raw)) .build()); log.error("영상 분석 결과를 반영하지 않고 따로 남겼습니다. reason={}, requestId={}, jobId={}, detail={}", - reason, requestId, jobId, detail); + reason, clipId(requestId), clipId(jobId), withoutNul(detail)); } @SuppressWarnings("unchecked") @@ -179,7 +287,8 @@ private Map parse(String raw) { return null; } try { - return objectMapper.readValue(raw, Map.class); + Object value = objectMapper.readValue(raw, Object.class); + return value instanceof Map map ? (Map) map : null; } catch (JacksonException e) { return null; } @@ -195,40 +304,23 @@ private String toJson(Map body) { /** 영상 결과 문서(result)의 videoId. failed는 문서가 없어 null이다. */ private static String videoId(Map body) { - return body.get("result") instanceof Map result ? textOf(result.get("videoId")) : null; + return body.get("result") instanceof Map result && result.get("videoId") instanceof String id ? id : null; } - /** - * 저장할 오류. 최상위 error(failed) → result.error(파일 문제) → result.analysis.error(분석기 미연결·부분 실패) - * 순서로 처음 있는 것. 파일 문제는 뒤의 둘에 같은 값이 온다. - */ - private static Map error(Map body) { - if (body.get("error") instanceof Map error) { - return error; - } - if (!(body.get("result") instanceof Map result)) { - return null; - } - if (result.get("error") instanceof Map error) { - return error; - } - return result.get("analysis") instanceof Map analysis && analysis.get("error") instanceof Map error - ? error - : null; + /** 기록·로그에 남길 식별자. 문자열이 아니면 남기지 않는다. */ + private static String idText(Map body, String field) { + return body.get(field) instanceof String text ? text : null; } - /** id는 문자열로 오지만 숫자로 와도 같은 값으로 본다. 객체·배열은 값이 없는 것으로 본다. */ - private static String text(Map body, String field) { - return textOf(body.get(field)); + private static String clipId(String value) { + return value == null || value.length() <= MAX_ID_COLUMN ? withoutNul(value) : withoutNul(value.substring(0, MAX_ID_COLUMN)); } - private static String textOf(Object value) { - if (value instanceof String text) { - return text.trim(); - } - if (value instanceof Number || value instanceof Boolean) { - return String.valueOf(value); - } - return null; + private static String clip(String value) { + return value.length() <= 100 ? value : value.substring(0, 100) + "…"; + } + + private static String withoutNul(String value) { + return value == null ? null : value.replace('\u0000', '�'); } } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisEligibility.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisEligibility.java new file mode 100644 index 00000000..83c7309d --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisEligibility.java @@ -0,0 +1,52 @@ +package repit.repit_api_server.domain.userdata.analysis.service; + +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode; +import repit.repit_api_server.domain.userdata.interview.service.InterviewFeedbackPolicy; + +/** + * 면접 영상을 행동 분석해도 되는지. 공통 정책({@link InterviewFeedbackPolicy})에 텍스트 면접 제외를 더한다. + * + *

    요청 생성·재전송·작업 조회 복구·자동/사용자 재분석·조회·늦은 콜백이 모두 이 판단을 거친다. 텍스트 면접 제외는 행동 + * 분석만의 규칙이다 — 정상 완료된 텍스트 면접도 내용 채점은 한다. + */ +final class VideoAnalysisEligibility { + + // 결과 없이 닫은 요청의 error_code. 화면에는 나가지 않는다(중단 면접은 조회가 409, 텍스트 면접은 NONE이다). + static final String INTERVIEW_ABANDONED = "INTERVIEW_ABANDONED"; + static final String INTERVIEW_NOT_FOUND = "INTERVIEW_NOT_FOUND"; + static final String TEXT_INTERVIEW = "TEXT_INTERVIEW"; + + enum Kind { + /** 정상 완료된 음성 면접. 분석한다. */ + RUN, + /** 중단·텍스트·없는 면접. 분석하지 않고 대기 요청은 정책 사유로 닫는다. */ + SKIP, + /** 아직 끝나지 않았다. 닫지 않고 기다린다. */ + HOLD + } + + record Verdict(Kind kind, String code, String message) { + } + + private VideoAnalysisEligibility() { + } + + static Verdict of(InterviewEntity interview) { + if (interview == null) { + return new Verdict(Kind.SKIP, INTERVIEW_NOT_FOUND, "면접을 찾을 수 없습니다."); + } + return switch (InterviewFeedbackPolicy.decide(interview)) { + case HOLD -> new Verdict(Kind.HOLD, null, "면접이 아직 끝나지 않았습니다."); + case SKIP -> new Verdict(Kind.SKIP, INTERVIEW_ABANDONED, "중단된 면접이라 영상 분석을 하지 않습니다."); + case RUN -> isText(interview) + ? new Verdict(Kind.SKIP, TEXT_INTERVIEW, "텍스트 면접은 영상 행동 분석을 하지 않습니다.") + : new Verdict(Kind.RUN, null, null); + }; + } + + /** 답변 방식이 비어 있는 옛 면접은 음성 면접으로 본다(답변 방식이 생기기 전에는 음성 면접뿐이었다). */ + static boolean isText(InterviewEntity interview) { + return interview.getAnswerMode() == AnswerMode.TEXT; + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisService.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisService.java index 70bf0dae..c20b0a1d 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisService.java @@ -10,6 +10,12 @@ import org.springframework.http.HttpStatusCode; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import repit.repit_api_server.domain.userdata.analysis.behavior.BehaviorData; +import repit.repit_api_server.domain.userdata.analysis.behavior.VideoPublicError; +import repit.repit_api_server.domain.userdata.analysis.behavior.VideoPublicErrors; +import repit.repit_api_server.domain.userdata.analysis.behavior.VideoResultDocument; +import repit.repit_api_server.domain.userdata.analysis.behavior.VideoResultException; import repit.repit_api_server.domain.userdata.analysis.dto.request.VideoAnalysisRequest; import repit.repit_api_server.domain.userdata.analysis.dto.response.VideoAnalysisAcceptedResponse; import repit.repit_api_server.domain.userdata.analysis.dto.response.VideoAnalysisJobResponse; @@ -20,6 +26,7 @@ import repit.repit_api_server.domain.userdata.analysis.repository.VideoAnalysisRepository; import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; +import repit.repit_api_server.domain.userdata.interview.service.InterviewFeedbackPolicy; import repit.repit_api_server.domain.userdata.recording.entity.InterviewRecordingEntity; import repit.repit_api_server.domain.userdata.recording.entity.enums.RecordingKind; import repit.repit_api_server.domain.userdata.recording.repository.InterviewRecordingRepository; @@ -44,6 +51,8 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; /** * 면접 화면 전체 녹화를 분석 서버의 영상 분석(POST /analysis/video)에 맡기고, 결과를 받을 때까지 지켜본다. @@ -63,6 +72,17 @@ *

  • 조회 장애·5xx·타임아웃만으로는 새 분석을 시작하지 않는다.
  • *
  • 자동 재분석은 분석 서버가 retryable=true로 끝낸 실패만, 묶음당 작업 5개까지. 사용자 재분석은 영상당 2회.
  • * + * + *

    정상 완료(COMPLETED)로 저장된 음성 면접만 분석한다({@link VideoAnalysisEligibility}). 새 요청·재전송·작업 조회 복구· + * 자동/사용자 재분석·조회·늦은 콜백이 모두 같은 판단을 거친다. 중단·텍스트 면접의 녹화는 보존하되 분석하지 않고, 진행 중인 + * 면접의 녹화는 완료될 때까지 기다린다. + * + *

    기능 스위치(app.video-analysis.enabled)는 새 작업을 만드는 것만 막는다 — 새 요청, 새 요청 id의 자동 재분석, 사용자 + * 재분석. 이미 맡긴 요청은 꺼져 있어도 같은 요청 id·저장한 본문으로 다시 보내고 작업을 조회하며 콜백도 받는다. 기존 작업을 + * 취소하는 스위치가 아니다. 저장된 결과는 꺼져 있어도 보여 준다. + * + *

    요청 행을 만드는 길은 모두 면접 행을 잠근 트랜잭션 안에서 자격·진행 중 요청·한도를 다시 보고 만든다. 분석 서버를 부르는 + * 느린 HTTP는 이 잠금 밖(스윕)에서 한다. */ @Service @RequiredArgsConstructor @@ -98,9 +118,22 @@ public class VideoAnalysisService { static final Duration CHECK_INTERVAL = Duration.ofMinutes(1); static final Duration RESEND_BASE_DELAY = Duration.ofSeconds(30); static final Duration REANALYSIS_DELAY = Duration.ofMinutes(1); + // 면접이 아직 끝나지 않은 요청을 다시 볼 간격. 닫지 않고 완료를 기다린다. + static final Duration HOLD_INTERVAL = Duration.ofMinutes(10); // 한 번의 스윕에서 단계마다 다루는 수. 켜는 순간 쌓인 영상이 한꺼번에 나가지 않게 한다. static final int BATCH_SIZE = 20; + // ---- 웹에 내보내는 사유(reasonCode). 오류 코드는 VideoPublicErrors가 정한다. ---- + static final String REASON_TEXT_INTERVIEW = "TEXT_INTERVIEW"; + static final String REASON_NO_RECORDING = "NO_RECORDING"; + static final String REASON_UPLOADED_BEFORE_ENABLED = "UPLOADED_BEFORE_ENABLED"; + static final String REASON_SOURCE_EXPIRED = "SOURCE_EXPIRED"; + static final String REASON_SERVICE_DISABLED = "SERVICE_DISABLED"; + static final String REASON_RESULT_SCHEMA_UNSUPPORTED = "RESULT_SCHEMA_UNSUPPORTED"; + // 하위 호환 result로 내보내는 완료 문서. failed 문서는 결과가 없어 내보내지 않는다. + private static final Set COMPLETED_DOCUMENTS = + Set.of(VideoAnalysisStatus.READY, VideoAnalysisStatus.PARTIAL, VideoAnalysisStatus.UNAVAILABLE); + private final VideoAnalysisRepository analysisRepository; private final InterviewRepository interviewRepository; private final InterviewRecordingRepository recordingRepository; @@ -108,6 +141,10 @@ public class VideoAnalysisService { private final AiServerClient aiServerClient; private final S3Presigner s3Presigner; private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; + + // 꺼져 있는데 요청 인증값도 없어 남은 요청을 확인하지 못한다는 경고를 한 번만 남긴다. + private final AtomicBoolean warnedNoToken = new AtomicBoolean(); // 분석 서버가 브라우저 샘플로 검증을 마치고 영상 분석을 열면 켠다. 꺼져 있으면 영상을 받아 두기만 한다. @Value("${app.video-analysis.enabled:false}") @@ -175,90 +212,189 @@ void requireSettings() { /** * 새로 올라온 최종 영상을 요청 자리에 올리고, 보낼 때가 된 요청을 보내거나 조회하고, 자동 재분석할 실패를 잇는다. * + *

    새 작업을 만드는 첫째·셋째 단계만 켜져 있을 때 한다. 둘째 단계(이미 맡긴 요청의 재전송·조회)는 꺼져 있어도 한다 — + * 끄는 순간 결과를 기다리던 요청이 영영 "분석 중"으로 남지 않게 한다. 둘째 단계는 새 요청 id를 만들지 않는다. + * *

    한 건이 넘어져도 나머지는 계속한다. */ @Scheduled(fixedDelayString = "${app.video-analysis.sweep-interval:30s}") public void sweep() { - if (!enabled) { - return; - } - LocalDateTime startedAt = now(); - LocalDateTime uploadedAfter = startedAt.minus(ANALYZABLE_FOR); - if (analyzeUploadedAfter != null && analyzeUploadedAfter.isAfter(uploadedAfter)) { - uploadedAfter = analyzeUploadedAfter; - } - for (InterviewRecordingEntity video : analysisRepository.findVideosToStart( - startedAt.minus(UPLOAD_GRACE), uploadedAfter, Limit.of(BATCH_SIZE))) { - guarded("영상 분석 요청을 만들지 못했습니다.", video.getInterviewId(), - () -> open(video, newChainId(), VideoAnalysisRequestedBy.AUTO)); + if (enabled) { + LocalDateTime startedAt = now(); + LocalDateTime uploadedAfter = startedAt.minus(ANALYZABLE_FOR); + if (analyzeUploadedAfter != null && analyzeUploadedAfter.isAfter(uploadedAfter)) { + uploadedAfter = analyzeUploadedAfter; + } + for (InterviewRecordingEntity video : analysisRepository.findVideosToStart( + startedAt.minus(UPLOAD_GRACE), uploadedAfter, Limit.of(BATCH_SIZE))) { + guarded("영상 분석 요청을 만들지 못했습니다.", video.getInterviewId(), () -> openFirst(video)); + } } // 방금 만든 요청도 이번에 보내도록 시각을 다시 잰다. - for (VideoAnalysisEntity analysis : analysisRepository.findDueChecks(now(), Limit.of(BATCH_SIZE))) { + List due = analysisRepository.findDueChecks(now(), Limit.of(BATCH_SIZE)); + if (!due.isEmpty() && !enabled && (videoApiToken == null || videoApiToken.isBlank())) { + // 꺼진 채 요청 인증값까지 빠졌다. 보내면 모두 401로 거절돼 멀쩡한 요청이 실패로 닫힌다. 확인을 미룬다. + if (warnedNoToken.compareAndSet(false, true)) { + log.warn("영상 분석이 꺼져 있고 VIDEO_ANALYSIS_API_TOKEN도 없어 남은 요청 {}건 이상을 확인하지 못합니다.", due.size()); + } + due = List.of(); + } + for (VideoAnalysisEntity analysis : due) { guarded("영상 분석 요청을 확인하지 못했습니다.", analysis.getInterviewId(), () -> check(analysis)); } - for (VideoAnalysisEntity failed : analysisRepository.findReanalysisCandidates( - now().minus(REANALYSIS_DELAY), MAX_JOBS_PER_CHAIN, Limit.of(BATCH_SIZE))) { - guarded("영상 분석을 자동으로 다시 맡기지 못했습니다.", failed.getInterviewId(), () -> reanalyze(failed)); + if (enabled) { + for (VideoAnalysisEntity failed : analysisRepository.findReanalysisCandidates( + now().minus(REANALYSIS_DELAY), MAX_JOBS_PER_CHAIN, Limit.of(BATCH_SIZE))) { + guarded("영상 분석을 자동으로 다시 맡기지 못했습니다.", failed.getInterviewId(), () -> reanalyze(failed)); + } } } - /** 웹이 보는 영상 분석 상태. */ + /** + * 웹이 보는 영상 분석 상태와 행동 관찰 결과. 본인 면접이 아니면 403, 없으면 404, 정상 완료되지 않은 면접이면 409다 — + * 인증·권한·존재 오류를 "데이터 없음"으로 감추지 않는다. 분석 서버를 부르지 않는다. + */ public VideoAnalysisResponse get(Long userId, Long interviewId) { - ownedInterview(userId, interviewId); - InterviewRecordingEntity video = finalVideo(interviewId); + InterviewEntity interview = ownedInterview(userId, interviewId); + InterviewFeedbackPolicy.requireEligible(interview); + return view(interview, now()); + } + + /** + * 최종 영상의 최신 요청으로 상태를 정하고, 같은 영상의 요청 가운데 검증을 통과한 가장 최근 ready/partial 결과를 따로 + * 골라 행동 데이터로 싣는다. 이전 영상의 결과는 섞지 않는다. + */ + VideoAnalysisResponse view(InterviewEntity interview, LocalDateTime now) { + if (VideoAnalysisEligibility.isText(interview)) { + // 과거 버그로 텍스트 면접에 녹화가 있어도 행동 분석은 보여 주지 않는다. + return VideoAnalysisResponse.empty(VideoAnalysisResponse.Status.NONE, REASON_TEXT_INTERVIEW); + } + InterviewRecordingEntity video = finalVideo(interview.getInterviewId()); if (video == null) { - return new VideoAnalysisResponse(VideoAnalysisResponse.Status.NONE, null, false); + return VideoAnalysisResponse.empty(VideoAnalysisResponse.Status.NONE, REASON_NO_RECORDING); } List analyses = analysisRepository.findAllByRecordingIdOrderByAnalysisIdAsc(video.getRecordingId()); if (analyses.isEmpty()) { - VideoAnalysisResponse.Status status = expired(video, now()) || uploadedBeforeEnabled(video) - ? VideoAnalysisResponse.Status.NONE - : VideoAnalysisResponse.Status.WAITING; - return new VideoAnalysisResponse(status, null, false); + if (uploadedBeforeEnabled(video)) { + return VideoAnalysisResponse.empty(VideoAnalysisResponse.Status.NONE, REASON_UPLOADED_BEFORE_ENABLED); + } + if (expired(video, now)) { + return VideoAnalysisResponse.empty(VideoAnalysisResponse.Status.NONE, REASON_SOURCE_EXPIRED); + } + if (!enabled) { + // 꺼져 있으면 이 영상은 요청되지 않는다. WAITING으로 두면 웹이 끝없이 기다린다. + return VideoAnalysisResponse.empty(VideoAnalysisResponse.Status.NONE, REASON_SERVICE_DISABLED); + } + return VideoAnalysisResponse.empty(VideoAnalysisResponse.Status.WAITING, null); } VideoAnalysisEntity latest = analyses.getLast(); - VideoAnalysisResponse.Status status = switch (latest.getStatus()) { - case PENDING -> VideoAnalysisResponse.Status.ANALYZING; - case READY -> VideoAnalysisResponse.Status.READY; - case PARTIAL -> VideoAnalysisResponse.Status.PARTIAL; - case UNAVAILABLE, FAILED -> autoReanalysisPending(latest, analyses) - ? VideoAnalysisResponse.Status.ANALYZING - : VideoAnalysisResponse.Status.FAILED; - }; - // 늦게 도착한 옛 결과가 새 결과를 가리지 않게, 요청 순서로 가장 나중의 완료 결과를 보여 준다. + VideoAnalysisResponse.Status status; + String reasonCode = null; + VideoPublicError error = null; + switch (latest.getStatus()) { + case PENDING -> status = VideoAnalysisResponse.Status.ANALYZING; + case READY -> status = VideoAnalysisResponse.Status.READY; + case PARTIAL -> status = VideoAnalysisResponse.Status.PARTIAL; + default -> { + if (autoReanalysisPending(latest, analyses)) { + status = VideoAnalysisResponse.Status.ANALYZING; + } else { + status = VideoAnalysisResponse.Status.FAILED; + error = VideoPublicErrors.forDisplay(latest.getErrorCode()); + reasonCode = error.code(); + } + } + } + + ShownBehavior shown = latestBehavior(analyses); + if ((status == VideoAnalysisResponse.Status.READY || status == VideoAnalysisResponse.Status.PARTIAL) + && (shown == null || shown.analysis() != latest)) { + // 최신 완료 결과를 이 서버가 읽을 수 없다(데이터 계약이 생기기 전의 옛 결과 등). 지어내지 않고 사유를 밝힌다. + reasonCode = REASON_RESULT_SCHEMA_UNSUPPORTED; + } + // 하위 호환용 원문. 요청 순서로 가장 나중의 완료 문서다 — 늦게 도착한 옛 결과가 새 결과를 가리지 않는다. Map result = analyses.reversed().stream() - .filter(analysis -> analysis.getResult() != null) + .filter(analysis -> analysis.getResult() != null && COMPLETED_DOCUMENTS.contains(analysis.getStatus())) .findFirst() .map(VideoAnalysisEntity::getResult) .orElse(null); - boolean canRetry = enabled && retryBlockedReason(video, analyses, now()) == null; - return new VideoAnalysisResponse(status, result, canRetry); + boolean canRetry = (status == VideoAnalysisResponse.Status.PARTIAL || status == VideoAnalysisResponse.Status.FAILED) + && retryBlockedReason(video, analyses, now) == null; + String latestId = String.valueOf(latest.getAnalysisId()); + String behaviorId = shown == null ? null : String.valueOf(shown.analysis().getAnalysisId()); + return new VideoAnalysisResponse(status, result, canRetry, reasonCode, error, + shown == null ? null : shown.data(), latestId, behaviorId, + shown != null && !behaviorId.equals(latestId)); + } + + private record ShownBehavior(VideoAnalysisEntity analysis, BehaviorData data) { + } + + /** + * 보여 줄 행동 데이터. 요청 순서의 역순으로 ready/partial 결과 가운데 콜백을 받을 때와 같은 검증을 통과한 첫 결과다. + * 상태 행과 같은 행이라고 가정하지 않는다. + */ + private static ShownBehavior latestBehavior(List analyses) { + for (VideoAnalysisEntity analysis : analyses.reversed()) { + if (analysis.getResult() == null || (analysis.getStatus() != VideoAnalysisStatus.READY + && analysis.getStatus() != VideoAnalysisStatus.PARTIAL)) { + continue; + } + try { + VideoResultDocument.Accepted accepted = + VideoResultDocument.validate(analysis.getResult(), String.valueOf(analysis.getRecordingId())); + if (accepted.behavior() != null + && VideoAnalysisStatus.fromResult(accepted.status()) == analysis.getStatus()) { + return new ShownBehavior(analysis, accepted.behavior()); + } + } catch (VideoResultException e) { + log.debug("저장된 영상 분석 결과를 행동 데이터로 읽을 수 없어 건너뜁니다. analysisId={}, 사유={}", + analysis.getAnalysisId(), e.getMessage()); + } + } + return null; } /** * 사용자가 재분석을 요청한다. 새 묶음을 열고 요청 자리만 만든다 — 보내는 것은 스윕이 곧바로 한다. + * + *

    면접 행을 잠근 트랜잭션 안에서 자격·진행 중 요청·사용자 재분석 한도를 다시 보고 행을 만든다. 두 탭에서 동시에 눌러도 + * 뒤의 요청은 앞 요청이 만든 행을 보고 409로 끝난다. 영상당 진행 중 요청 하나라는 DB 인덱스는 마지막 방어선일 뿐 한도 + * 검사를 대신하지 않는다. */ public VideoAnalysisResponse retry(Long userId, Long interviewId) { ownedInterview(userId, interviewId); - if (!enabled) { - throw BusinessException.conflict("영상 분석을 아직 사용할 수 없습니다."); - } - InterviewRecordingEntity video = finalVideo(interviewId); - if (video == null) { - throw BusinessException.notFound("분석할 면접 영상이 없습니다."); - } - List analyses = - analysisRepository.findAllByRecordingIdOrderByAnalysisIdAsc(video.getRecordingId()); - String blocked = retryBlockedReason(video, analyses, now()); - if (blocked != null) { - throw BusinessException.conflict(blocked); - } - if (open(video, newChainId(), VideoAnalysisRequestedBy.USER) == null) { + try { + transactionTemplate.executeWithoutResult(tx -> { + InterviewEntity interview = interviewRepository.lockById(interviewId) + .orElseThrow(() -> BusinessException.notFound("면접을 찾을 수 없습니다")); + InterviewFeedbackPolicy.requireEligible(interview); + if (VideoAnalysisEligibility.isText(interview)) { + throw BusinessException.conflict("텍스트 면접은 영상 분석을 하지 않습니다."); + } + if (!enabled) { + throw BusinessException.conflict("영상 분석을 아직 사용할 수 없습니다."); + } + InterviewRecordingEntity video = finalVideo(interviewId); + if (video == null) { + throw BusinessException.notFound("분석할 면접 영상이 없습니다."); + } + List analyses = + analysisRepository.findAllByRecordingIdOrderByAnalysisIdAsc(video.getRecordingId()); + String blocked = retryBlockedReason(video, analyses, now()); + if (blocked != null) { + throw BusinessException.conflict(blocked); + } + insert(video, interview, newChainId(), VideoAnalysisRequestedBy.USER); + log.info("사용자가 영상 분석을 다시 요청했습니다. interviewId={}, recordingId={}", + interviewId, video.getRecordingId()); + }); + } catch (DataIntegrityViolationException e) { + // 잠금 밖에서 행을 만드는 옛 경로나 다른 서버가 먼저 자리를 차지했다. throw BusinessException.conflict("이미 분석 중입니다."); } - log.info("사용자가 영상 분석을 다시 요청했습니다. interviewId={}, recordingId={}", interviewId, video.getRecordingId()); return get(userId, interviewId); } @@ -268,16 +404,21 @@ public VideoAnalysisResponse retry(Long userId, Long interviewId) { *

    READY는 같은 영상이면 같은 결과가 나와 비용만 든다. 자동 재분석이 남아 있으면 그것을 기다린다. 분석 서버가 * retryable=false로 끝낸 실패는 영상을 새로 올려야 한다. 보관 기간은 요청이 아니라 영상이 올라온 시각으로 센다 — * 요청 시각으로 세면 재분석할 때마다 기간이 늘어 이미 지워진 파일로 요청하게 된다. + * + *

    분석 기준 시각(analyze-uploaded-after)은 요청이 한 번도 없는 영상에만 적용한다. 다시 켜면서 기준 시각을 옮겨도 이미 + * 정상적으로 분석한 영상의 재분석까지 막지는 않는다. */ String retryBlockedReason(InterviewRecordingEntity video, List analyses, LocalDateTime now) { + if (!enabled) { + return "영상 분석을 아직 사용할 수 없습니다."; + } if (expired(video, now)) { return "녹화한 지 90일이 지나 다시 분석할 수 없습니다."; } - if (uploadedBeforeEnabled(video)) { - return "영상 분석을 시작하기 전에 녹화한 영상이라 분석할 수 없습니다."; - } if (analyses.isEmpty()) { - return "아직 분석을 시작하지 않았습니다."; + return uploadedBeforeEnabled(video) + ? "영상 분석을 시작하기 전에 녹화한 영상이라 분석할 수 없습니다." + : "아직 분석을 시작하지 않았습니다."; } VideoAnalysisEntity latest = analyses.getLast(); if (latest.getStatus() == VideoAnalysisStatus.PENDING) { @@ -303,8 +444,14 @@ String retryBlockedReason(InterviewRecordingEntity video, List analyses) { + /** + * 스윕이 곧 이 묶음을 자동으로 이어 갈지. {@code findReanalysisCandidates}와 같은 조건이다. 꺼져 있으면 자동 재분석을 + * 만들지 않으므로 이어 가지 않는다 — 그때 화면은 "분석 중"이 아니라 FAILED다. + */ + private boolean autoReanalysisPending(VideoAnalysisEntity latest, List analyses) { + if (!enabled) { + return false; + } boolean retryableFailure = (latest.getStatus() == VideoAnalysisStatus.FAILED || latest.getStatus() == VideoAnalysisStatus.UNAVAILABLE) && Boolean.TRUE.equals(latest.getErrorRetryable()); @@ -314,16 +461,70 @@ private static boolean autoReanalysisPending(VideoAnalysisEntity latest, List보낼 수 없는 영상(세션 없음, 입력 제한을 넘는 옛 파일 등)은 보내지 않고 닫힌 행으로 남긴다. 남기지 않으면 - * 스윕이 그 영상을 계속 다시 집는다. + *

    스윕이 고른 뒤 잠그기 전까지 면접이 중단으로 확정되거나, 새 영상이 올라오거나, 사용자 재분석이 먼저 자리를 차지할 수 + * 있다. 고를 때의 판단을 믿지 않고 잠근 뒤 다시 본다. + * + * @param stillWanted 그 영상의 요청 기록(오래된 것부터)을 보고 지금도 만들어야 하는지 + * @return 만든 행. 만들 이유가 없어졌거나 같은 영상에 이미 살아 있는 요청이 있으면 null + */ + private VideoAnalysisEntity openLocked(InterviewRecordingEntity video, String chainId, + VideoAnalysisRequestedBy requestedBy, + Predicate> stillWanted) { + try { + return transactionTemplate.execute(tx -> { + InterviewEntity interview = interviewRepository.lockById(video.getInterviewId()).orElse(null); + if (VideoAnalysisEligibility.of(interview).kind() != VideoAnalysisEligibility.Kind.RUN) { + return null; + } + InterviewRecordingEntity finalVideo = finalVideo(video.getInterviewId()); + if (finalVideo == null || !finalVideo.getRecordingId().equals(video.getRecordingId())) { + return null; + } + List analyses = + analysisRepository.findAllByRecordingIdOrderByAnalysisIdAsc(video.getRecordingId()); + if (!stillWanted.test(analyses)) { + return null; + } + return insert(video, interview, chainId, requestedBy); + }); + } catch (DataIntegrityViolationException e) { + log.info("같은 영상에 진행 중인 영상 분석이 있어 새로 만들지 않습니다. recordingId={}", video.getRecordingId()); + return null; + } + } + + /** + * 잠금 없이 요청 자리만 만든다. 같은 영상의 살아 있는 요청 하나라는 DB 인덱스가 마지막으로 막는다. 운영 경로는 + * {@link #openLocked}를 쓴다. * * @return 만든 행. 같은 영상에 이미 살아 있는 요청이 있으면 null */ VideoAnalysisEntity open(InterviewRecordingEntity video, String chainId, VideoAnalysisRequestedBy requestedBy) { - InterviewEntity interview = interviewRepository.findById(video.getInterviewId()).orElse(null); + try { + return insert(video, interviewRepository.findById(video.getInterviewId()).orElse(null), chainId, requestedBy); + } catch (DataIntegrityViolationException e) { + // 다른 서버(배포 중 두 대)나 연달아 누른 재분석이 먼저 자리를 차지했다. + log.info("같은 영상에 진행 중인 영상 분석이 있어 새로 만들지 않습니다. recordingId={}", video.getRecordingId()); + return null; + } + } + + /** + * 요청 자리를 만든다. 보내는 것은 스윕이 한다. 같은 영상에 살아 있는 요청이 있으면 DB 인덱스 위반이 그대로 올라간다. + * + *

    보낼 수 없는 영상(세션 없음, 입력 제한을 넘는 옛 파일 등)은 보내지 않고 닫힌 행으로 남긴다. 남기지 않으면 + * 스윕이 그 영상을 계속 다시 집는다. 서명 URL은 이 서버 안에서 계산만 하고 S3를 부르지 않는다. + */ + private VideoAnalysisEntity insert(InterviewRecordingEntity video, InterviewEntity interview, String chainId, + VideoAnalysisRequestedBy requestedBy) { String requestId = "video-" + UUID.randomUUID(); Rejection rejection = rejection(video, interview); LocalDateTime now = now(); @@ -348,14 +549,7 @@ VideoAnalysisEntity open(InterviewRecordingEntity video, String chainId, VideoAn .nextCheckAt(now); } - VideoAnalysisEntity saved; - try { - saved = analysisRepository.save(row.build()); - } catch (DataIntegrityViolationException e) { - // 다른 서버(배포 중 두 대)나 연달아 누른 재분석이 먼저 자리를 차지했다. - log.info("같은 영상에 진행 중인 영상 분석이 있어 새로 만들지 않습니다. recordingId={}", video.getRecordingId()); - return null; - } + VideoAnalysisEntity saved = analysisRepository.save(row.build()); if (rejection != null) { log.warn("영상 분석을 보낼 수 없어 닫았습니다. interviewId={}, recordingId={}, code={}, 사유={}", video.getInterviewId(), video.getRecordingId(), rejection.code(), rejection.message()); @@ -426,6 +620,18 @@ private String presign(String key) { * */ void check(VideoAnalysisEntity analysis) { + // 자격을 먼저 본다. 중단·텍스트 면접이면 분석 서버를 부르지 않고 닫고, 진행 중이면 닫지 않고 기다린다. + VideoAnalysisEligibility.Verdict verdict = VideoAnalysisEligibility.of( + interviewRepository.findById(analysis.getInterviewId()).orElse(null)); + if (verdict.kind() == VideoAnalysisEligibility.Kind.SKIP) { + fail(analysis, verdict.code(), false, verdict.message()); + return; + } + if (verdict.kind() == VideoAnalysisEligibility.Kind.HOLD) { + hold(analysis); + return; + } + if (analysis.getJobId() == null) { if (canSendAgain(analysis, analysis.getSendCount())) { send(analysis); @@ -453,16 +659,36 @@ void check(VideoAnalysisEntity analysis) { String status = job == null || job.getStatus() == null ? "" : job.getStatus().trim().toLowerCase(Locale.ROOT); switch (status) { - case "completed" -> { - if (!callbackHandler.applyRecovered(analysis.getAnalysisId(), job.getResult())) { - fail(analysis, "INVALID_RESULT", null, "작업 조회로 받은 결과가 검증을 통과하지 못했습니다."); + // 종료된 작업의 result는 콜백 본문 전체다(failed 포함). 콜백과 같은 검증을 거쳐 저장한다. + case "completed" -> applyRecovered(analysis, job.getResult()); + case "failed" -> { + if (job.getResult() != null) { + applyRecovered(analysis, job.getResult()); + } else { + failFromJob(analysis, job.getError()); } } - case "failed" -> failFromJob(analysis, job.getError()); default -> waitOrGiveUp(analysis, "작업이 아직 끝나지 않았습니다. status=" + status); } } + private void applyRecovered(VideoAnalysisEntity analysis, Map result) { + switch (callbackHandler.applyRecovered(analysis.getAnalysisId(), result)) { + case INVALID -> fail(analysis, "INVALID_RESULT", null, "작업 조회로 받은 결과가 검증을 통과하지 못했습니다."); + case POLICY_HELD -> hold(analysis); + default -> { + // 저장했거나, 이미 같은 결과가 있거나, 정책상 닫았다. + } + } + } + + /** 면접이 아직 끝나지 않았다. 닫지 않고 나중에 다시 본다. */ + private void hold(VideoAnalysisEntity analysis) { + LocalDateTime now = now(); + analysisRepository.postpone(analysis.getAnalysisId(), now.plus(HOLD_INTERVAL), + "면접이 아직 끝나지 않아 결과 확인을 미룹니다.", now); + } + /** * 저장해 둔 본문을 그대로 보낸다. * @@ -563,13 +789,16 @@ private void waitOrGiveUp(VideoAnalysisEntity analysis, String reason) { analysisRepository.postpone(analysis.getAnalysisId(), now.plus(CHECK_INTERVAL), reason, now); } + /** 결과 문서 없이 실패만 알려 온 작업. 오류도 공개 오류 계약(코드·retryable)에 맞아야 그대로 받는다. */ private void failFromJob(VideoAnalysisEntity analysis, Map error) { - Object code = error == null ? null : error.get("code"); - Object retryable = error == null ? null : error.get("retryable"); - Object message = error == null ? null : error.get("message"); - fail(analysis, code == null ? "UNKNOWN" : String.valueOf(code), - retryable instanceof Boolean flag ? flag : null, - message == null ? "분석 서버가 작업 실패를 알렸습니다." : String.valueOf(message)); + String code; + try { + code = VideoPublicErrors.validateRemote(error, "$.error"); + } catch (VideoResultException e) { + fail(analysis, "INVALID_RESULT", null, "작업 조회의 실패 오류가 공개 오류 계약에 맞지 않습니다. " + e.getMessage()); + return; + } + fail(analysis, code, VideoPublicErrors.retryableOf(code), String.valueOf(error.get("message"))); } private void fail(VideoAnalysisEntity analysis, String code, Boolean retryable, String message) { @@ -589,7 +818,12 @@ private void reanalyze(VideoAnalysisEntity failed) { if (video == null) { return; } - if (open(video, failed.getChainId(), failed.getRequestedBy()) != null) { + // 잠근 뒤에도 그 실패가 영상의 최신 요청이고 묶음에 자리가 남았는지 다시 본다. 그 사이 사용자 재분석이 새 묶음을 + // 열었으면 잇지 않는다. + VideoAnalysisEntity opened = openLocked(video, failed.getChainId(), failed.getRequestedBy(), analyses -> + !analyses.isEmpty() && analyses.getLast().getAnalysisId().equals(failed.getAnalysisId()) + && autoReanalysisPending(analyses.getLast(), analyses)); + if (opened != null) { log.info("재시도할 만한 실패라 영상 분석을 다시 맡깁니다. interviewId={}, 이전 analysisId={}, code={}", failed.getInterviewId(), failed.getAnalysisId(), failed.getErrorCode()); } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java index d7ed2ef5..df81f1f6 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java @@ -17,6 +17,15 @@ public interface InterviewRepository extends JpaRepository findForJobSnapshot(@Param("interviewId") Long interviewId); + /** + * 면접 행을 잠그고 읽는다. 같은 면접에 영상 분석 요청을 여는 길(자동 요청·자동 재분석·사용자 재분석)이 이 잠금 아래에서 + * 자격·진행 중 요청·사용자 재분석 한도를 다시 보고 행을 만든다. 두 탭에서 동시에 눌러도 한쪽은 다른 쪽이 만든 행을 보고 + * 멈춘다. 외부 호출은 이 잠금 안에서 하지 않는다. + */ + @org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + @Query("select i from InterviewEntity i where i.interviewId = :interviewId") + Optional lockById(@Param("interviewId") Long interviewId); + List findAllByUserId(Long userId); Optional findBySessionId(String sessionId); diff --git a/src/main/java/repit/repit_api_server/global/logging/RequestLoggingFilter.java b/src/main/java/repit/repit_api_server/global/logging/RequestLoggingFilter.java index f5ebc744..3bc5fd34 100644 --- a/src/main/java/repit/repit_api_server/global/logging/RequestLoggingFilter.java +++ b/src/main/java/repit/repit_api_server/global/logging/RequestLoggingFilter.java @@ -25,6 +25,7 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.StringJoiner; import java.util.UUID; @@ -57,6 +58,13 @@ public class RequestLoggingFilter extends OncePerRequestFilter { // 끝나지 않고 계속 흘러가는 응답. 로그를 위해 감쌌다가는 이벤트가 캐시에 갇힌다. private static final String SSE_PATH_PREFIX = "/api/v1/ai/subscribe/"; + // 본문을 로그에 남기지 않는 경로. 영상 행동 관찰 데이터는 요약 수치라도 로그로 퍼뜨리지 않는다. + // 영상 콜백은 이 필터가 먼저 읽지도 않는다 — 내부 인증을 통과한 뒤 컨트롤러가 상한까지만 읽는다. + private static final List BODY_EXCLUDED_PATH_PATTERNS = List.of( + "/api/analyses/video/callback", + "/api/interviews/*/video-analysis", + "/api/interviews/*/video-analysis/retry"); + private final HttpLoggingProperties properties; private final AntPathMatcher pathMatcher = new AntPathMatcher(); @@ -279,7 +287,7 @@ private void logAsyncEnd(String result, Throwable throwable) { * 파일 내용은 남기지 않고 이름과 크기만 남긴다. */ private void appendFormSummary(StringBuilder message, HttpServletRequest request, String contentType) { - if (!properties.includeBody() || contentType == null) { + if (!properties.includeBody() || contentType == null || bodyExcluded(request)) { return; } String lowerCase = contentType.toLowerCase(); @@ -341,7 +349,7 @@ private void appendBody(StringBuilder message, byte[] body, String contentType, } private boolean shouldCacheRequestBody(HttpServletRequest request) { - if (!properties.includeBody()) { + if (!properties.includeBody() || bodyExcluded(request)) { return false; } String contentType = request.getContentType(); @@ -355,7 +363,14 @@ private boolean shouldCacheRequestBody(HttpServletRequest request) { return false; } } - return request.getContentLengthLong() <= MAX_CACHEABLE_BODY_BYTES; + // 길이를 밝히지 않은 본문(chunked)은 끝까지 읽어 봐야 크기를 안다. 상한 없이 메모리에 올리지 않는다. + long contentLength = request.getContentLengthLong(); + return contentLength >= 0 && contentLength <= MAX_CACHEABLE_BODY_BYTES; + } + + private boolean bodyExcluded(HttpServletRequest request) { + String path = request.getRequestURI(); + return BODY_EXCLUDED_PATH_PATTERNS.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); } /** @@ -371,7 +386,7 @@ private boolean shouldCacheRequestBody(HttpServletRequest request) { * 비켜간다. */ private boolean shouldCacheResponseBody(HttpServletRequest request) { - if (!properties.includeBody()) { + if (!properties.includeBody() || bodyExcluded(request)) { return false; } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f0541e09..82822a24 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -125,17 +125,22 @@ app: # 음성 분석에 넘기는 답변 음성의 서명 주소 유효 시간. 접수 뒤 작업이 밀려도 만료되지 않을 만큼 둔다. recording-url-ttl: 6h video-analysis: - # 분석 서버가 Chrome·Safari 샘플로 검증을 마치고 영상 분석을 열면 켠다. 꺼져 있으면 면접 화면 녹화를 받아 - # 두기만 하고 보내지 않는다. 켠 뒤에는 analyze-uploaded-after 이후에 올라온 최종 영상만 스윕마다 20건씩 나간다. + # 분석 서버가 Chrome·Safari 샘플로 검증을 마치고 영상 분석을 열면 켠다. 꺼져 있으면 새 작업만 만들지 않는다 -- 새 요청, + # 새 요청 id의 자동 재분석, 사용자 재분석. 이미 맡긴 요청은 꺼져 있어도 같은 요청 id로 다시 보내고 작업을 조회하며 콜백도 + # 받는다(취소 스위치가 아니다). 저장된 결과는 꺼져 있어도 보인다. 켠 뒤에는 analyze-uploaded-after 이후에 올라온, + # 정상 완료된 음성 면접의 최종 영상만 스윕마다 20건씩 나간다. # 켜려면 INTERNAL_AUTH_TOKEN이 있어야 한다. 없으면 서버가 뜨지 않는다 -- 영상 콜백은 인증 없이 받지 않는다. # 값을 넣으면 기존 콜백에도 검사가 강제되므로, 분석·채팅 서버가 헤더를 보내도록 배포한 뒤에 켠다. enabled: ${VIDEO_ANALYSIS_ENABLED:false} # 분석 서버로 보내는 영상 요청·작업 조회에 X-Internal-Token으로 싣는 값. 분석 서버 VIDEO_API_TOKEN과 같아야 한다. # 콜백 인증값(INTERNAL_AUTH_TOKEN)과는 다른 값이다 -- 서로 바꿔 쓰지 않는다. 켰는데 비어 있으면 서버가 뜨지 않는다. api-token: ${VIDEO_ANALYSIS_API_TOKEN:} - # 영상 분석을 켠 시각. 오프셋을 포함한 ISO-8601로 넣는다(예: 2026-10-20T09:00:00+09:00). 이 시각까지 올라온 영상은 - # 보내지 않고, 상태 조회는 NONE, 재분석은 409로 받는다. 켰는데 비어 있거나 형식이 틀리면 서버가 뜨지 않는다 -- - # 비워 둔 것을 "제한 없음"으로 읽으면 켜는 순간 그동안 쌓인 영상(업로드 89일 이내)이 모두 나간다. + # 영상 분석을 켠 시각. 오프셋을 포함한 ISO-8601로 넣는다(예: 2026-10-20T09:00:00+09:00). 이 시각까지 올라와 요청이 한 번도 + # 없는 영상은 보내지 않고, 상태 조회는 NONE, 재분석은 409로 받는다. 이미 요청한 적 있는 영상에는 적용하지 않는다. + # 켰는데 비어 있거나 형식이 틀리면 서버가 뜨지 않는다 -- 비워 둔 것을 "제한 없음"으로 읽으면 켜는 순간 그동안 쌓인 + # 영상(업로드 89일 이내)이 모두 나간다. + # 장애 복구로 다시 띄울 때는 값을 바꾸지 않는다. 일부러 껐다가 다시 켤 때는 다시 켠 시각으로 바꾼다 -- 꺼져 있는 동안 + # 올라온 영상을 분석할지는 아직 정하지 않았으므로 보내지 않는다. analyze-uploaded-after: ${VIDEO_ANALYSIS_UPLOADED_AFTER:} # 업로드를 마친 영상을 요청 자리에 올리고, 보내고, 작업을 조회하고, 자동 재분석을 잇는 간격. # 재전송·조회·재분석 한도는 분석 서버와 맞춘 계약이라 코드(VideoAnalysisService)에 둔다. diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/controller/VideoAnalysisControllerTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/controller/VideoAnalysisControllerTest.java index 6d84046a..8053cdd0 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/analysis/controller/VideoAnalysisControllerTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/controller/VideoAnalysisControllerTest.java @@ -5,15 +5,26 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import repit.repit_api_server.domain.userdata.analysis.service.VideoAnalysisCallbackHandler; import repit.repit_api_server.domain.userdata.analysis.service.VideoAnalysisService; import repit.repit_api_server.global.error.GlobalExceptionHandler; +import repit.repit_api_server.global.exception.BusinessException; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -55,4 +66,62 @@ void setUp() { verify(callbackHandler).handle(isNull()); } + + /** 1MiB를 넘는 본문은 읽기 전에 413으로 끊는다. 넘는 원문을 따로 남기는 기록에도 쌓지 않는다. */ + @Test + void 밝힌_길이가_상한을_넘으면_읽지_않고_413이다() throws Exception { + byte[] big = new byte[VideoAnalysisCallbackHandler.MAX_BODY_BYTES + 1]; + java.util.Arrays.fill(big, (byte) 'a'); + + mockMvc.perform(post("/api/analyses/video/callback").contentType(MediaType.APPLICATION_JSON).content(big)) + .andExpect(status().isContentTooLarge()); + + verifyNoInteractions(callbackHandler); + } + + @Test + void 상한까지는_받는다() throws Exception { + String atLimit = "{\"pad\":\"" + "a".repeat(VideoAnalysisCallbackHandler.MAX_BODY_BYTES - 10) + "\"}"; + assertThat(atLimit.getBytes(StandardCharsets.UTF_8)).hasSize(VideoAnalysisCallbackHandler.MAX_BODY_BYTES); + + mockMvc.perform(post("/api/analyses/video/callback").contentType(MediaType.APPLICATION_JSON).content(atLimit)) + .andExpect(status().isOk()); + + verify(callbackHandler).handle(atLimit); + } + + /** 길이를 밝히지 않은(chunked) 본문도 상한까지만 읽는다. */ + @Test + void 길이를_밝히지_않은_본문도_상한을_넘으면_413이다() { + MockHttpServletRequest chunked = new MockHttpServletRequest("POST", "/api/analyses/video/callback") { + @Override + public long getContentLengthLong() { + return -1; + } + + @Override + public int getContentLength() { + return -1; + } + }; + chunked.setContent(new byte[VideoAnalysisCallbackHandler.MAX_BODY_BYTES + 1]); + VideoAnalysisController controller = new VideoAnalysisController(videoAnalysisService, callbackHandler); + + assertThatThrownBy(() -> controller.callback(chunked)) + .isInstanceOfSatisfying(BusinessException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONTENT_TOO_LARGE)); + verifyNoInteractions(callbackHandler); + } + + /** UTF-8로 읽을 수 없는 본문은 JSON으로 해석하지 않고 깨진 바이트를 바꾼 원문만 따로 남긴다. */ + @Test + void UTF8이_아닌_본문은_따로_남기고_200이다() throws Exception { + byte[] latin1 = "{\"status\":\"r\u00e9ady\"}".getBytes(StandardCharsets.ISO_8859_1); + + mockMvc.perform(post("/api/analyses/video/callback").contentType(MediaType.APPLICATION_JSON).content(latin1)) + .andExpect(status().isOk()); + + verify(callbackHandler).keepUndecodable(anyString()); + verify(callbackHandler, never()).handle(any()); + } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java index ed7411df..f96ff002 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java @@ -7,46 +7,64 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Limit; +import org.springframework.http.HttpStatus; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; import repit.repit_api_server.domain.userdata.analysis.dto.response.VideoAnalysisAcceptedResponse; import repit.repit_api_server.domain.userdata.analysis.dto.response.VideoAnalysisJobResponse; +import repit.repit_api_server.domain.userdata.analysis.dto.response.VideoAnalysisResponse; import repit.repit_api_server.domain.userdata.analysis.entity.VideoAnalysisEntity; import repit.repit_api_server.domain.userdata.analysis.entity.enums.VideoAnalysisRequestedBy; import repit.repit_api_server.domain.userdata.analysis.entity.enums.VideoAnalysisStatus; import repit.repit_api_server.domain.userdata.analysis.repository.VideoAnalysisRepository; import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode; import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; import repit.repit_api_server.domain.userdata.recording.entity.InterviewRecordingEntity; import repit.repit_api_server.domain.userdata.recording.entity.enums.RecordingKind; import repit.repit_api_server.domain.userdata.recording.repository.InterviewRecordingRepository; import repit.repit_api_server.global.client.AiServerClient; +import repit.repit_api_server.global.exception.BusinessException; import tools.jackson.core.type.TypeReference; import tools.jackson.databind.json.JsonMapper; +import java.io.IOException; +import java.io.InputStream; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 영상 분석이 실제 스키마 위에서 규칙대로 도는지 본다 — 부분 UNIQUE 인덱스, 조건부 갱신, jsonb, 그리고 스윕이 고르는 - * 쿼리. 이것들은 플러시나 실제 SQL에서야 드러난다. + * 영상 분석이 실제 PostgreSQL 위에서 규칙대로 도는지 본다 — 부분 UNIQUE 인덱스, 조건부 갱신, 행 잠금, jsonb, 그리고 스윕이 + * 고르는 쿼리. 이것들은 플러시나 실제 SQL, 두 연결이 겹칠 때에야 드러난다. * - *

    DB 제약 위반을 일으켜야 해서 테스트 트랜잭션으로 감싸지 않는다(위반 뒤 트랜잭션이 깨진다). 만든 행은 끝나고 지운다. + *

    결과 본문은 Web PR #189의 계약 예제(contracts/callback-*.json)에서 식별자만 이 요청의 값으로 바꿔 쓴다. + * + *

    DB 제약 위반과 두 연결의 경쟁을 일으켜야 해서 테스트 트랜잭션으로 감싸지 않는다. 만든 행은 끝나고 지운다. * 스윕은 한 시간 간격으로 미뤄 테스트 도중 끼어들지 않게 한다. */ @SpringBootTest(properties = "app.video-analysis.sweep-interval=1h") class VideoAnalysisPersistenceTest { + private static final JsonMapper JSON = JsonMapper.builder().build(); + @Autowired private VideoAnalysisService service; @Autowired @@ -58,6 +76,8 @@ class VideoAnalysisPersistenceTest { @Autowired private InterviewRecordingRepository recordingRepository; @Autowired + private TransactionTemplate transactionTemplate; + @Autowired private JdbcTemplate jdbc; @MockitoBean @@ -66,9 +86,15 @@ class VideoAnalysisPersistenceTest { private final List interviewIds = new ArrayList<>(); private final String sessionId = "s-" + UUID.randomUUID(); private InterviewEntity interview; + private Object enabledSetting; + private Object uploadedAfterSetting; + private Object videoApiTokenSetting; @BeforeEach void setUp() { + enabledSetting = ReflectionTestUtils.getField(service, "enabled"); + uploadedAfterSetting = ReflectionTestUtils.getField(service, "analyzeUploadedAfter"); + videoApiTokenSetting = ReflectionTestUtils.getField(service, "videoApiToken"); interview = interviewRepository.save(InterviewEntity.builder() .userId(77L).sessionId(sessionId).status(Status.COMPLETED).build()); interviewIds.add(interview.getInterviewId()); @@ -76,6 +102,9 @@ void setUp() { @AfterEach void tearDown() { + ReflectionTestUtils.setField(service, "enabled", enabledSetting); + ReflectionTestUtils.setField(service, "analyzeUploadedAfter", uploadedAfterSetting); + ReflectionTestUtils.setField(service, "videoApiToken", videoApiTokenSetting); for (Long interviewId : interviewIds) { jdbc.update("delete from unmatched_callback where request_id in " + "(select request_id from video_analysis where interview_id = ?)", interviewId); @@ -83,16 +112,19 @@ void tearDown() { jdbc.update("delete from interview_recording where interview_id = ?", interviewId); jdbc.update("delete from interview where interview_id = ?", interviewId); } - jdbc.update("delete from unmatched_callback where payload like ?", "%" + sessionId + "%"); + jdbc.update("delete from unmatched_callback where payload like ? or detail like ?", + "%" + sessionId + "%", "%" + sessionId + "%"); } + // ---- 요청 자리 ---- + @Test void 요청_본문은_jsonb로_남고_다시_보낼_때_같은_값으로_나간다() { InterviewRecordingEntity video = video(interview, LocalDateTime.now().minusMinutes(5)); - VideoAnalysisEntity opened = service.open(video, "chain-" + UUID.randomUUID(), VideoAnalysisRequestedBy.AUTO); + VideoAnalysisEntity opened = service.openFirst(video); when(aiServerClient.requestVideoAnalysis(any())).thenReturn(new VideoAnalysisAcceptedResponse("job-" + sessionId)); - VideoAnalysisEntity stored = analysisRepository.findById(opened.getAnalysisId()).orElseThrow(); + VideoAnalysisEntity stored = reload(opened); service.check(stored); verify(aiServerClient).requestVideoAnalysis(stored.getRequestPayload()); @@ -101,7 +133,7 @@ void tearDown() { assertThat(sentVideo.get("videoId")).isEqualTo(String.valueOf(video.getRecordingId())); assertThat((String) sentVideo.get("fileUrl")).contains("X-Amz-Signature="); - VideoAnalysisEntity acknowledged = analysisRepository.findById(opened.getAnalysisId()).orElseThrow(); + VideoAnalysisEntity acknowledged = reload(opened); assertThat(acknowledged.getJobId()).isEqualTo("job-" + sessionId); assertThat(acknowledged.getSendCount()).isEqualTo(1); assertThat(acknowledged.getNextCheckAt()).isAfter(LocalDateTime.now().plusMinutes(9)); @@ -119,85 +151,48 @@ void tearDown() { .isInstanceOf(DataIntegrityViolationException.class); } - @Test - void 콜백_결과는_jsonb로_저장되고_재전송에도_한_행이다() { - VideoAnalysisEntity pending = analysisRepository.save( - row(video(interview, LocalDateTime.now()), "chain-a", VideoAnalysisStatus.PENDING, null)); - String body = callback(pending, "job-" + sessionId, "unavailable", - ",\"error\":{\"code\":\"PROCESSING_TIMEOUT\",\"message\":\"t\",\"retryable\":true},\"metrics\":{\"gaze\":0.8}"); - - callbackHandler.handle(body); - callbackHandler.handle(body); - - VideoAnalysisEntity stored = analysisRepository.findById(pending.getAnalysisId()).orElseThrow(); - assertThat(stored.getStatus()).isEqualTo(VideoAnalysisStatus.UNAVAILABLE); - assertThat(stored.getJobId()).isEqualTo("job-" + sessionId); - assertThat(stored.getErrorCode()).isEqualTo("PROCESSING_TIMEOUT"); - assertThat(stored.getErrorRetryable()).isTrue(); - assertThat(stored.getResult()).containsKey("metrics"); - assertThat(analysisRepository.findAllByRecordingIdOrderByAnalysisIdAsc(pending.getRecordingId())).hasSize(1); - assertThat(unmatchedCount()).isZero(); - } - - @Test - void 모르는_요청이나_식별자가_다른_결과는_원문만_따로_남기고_반영하지_않는다() { - VideoAnalysisEntity pending = analysisRepository.save( - row(video(interview, LocalDateTime.now()), "chain-a", VideoAnalysisStatus.PENDING, null)); - - callbackHandler.handle(callback(pending, "job-" + sessionId, "ready", "") - .replace(pending.getRequestId(), "video-unknown-" + sessionId)); - callbackHandler.handle(callback(pending, "job-" + sessionId, "ready", "") - .replace("\"interviewId\":\"" + interview.getInterviewId() + "\"", "\"interviewId\":\"1\"")); - callbackHandler.handle(callback(pending, "job-" + sessionId, "done", "")); - callbackHandler.handle(callback(pending, "job-" + sessionId, "ready", "").replace("\"schemaVersion\":\"1\"", "\"schemaVersion\":\"2\"")); - callbackHandler.handle("not json " + sessionId); - - assertThat(jdbc.queryForList("select reason from unmatched_callback where payload like ? order by callback_id", - String.class, "%" + sessionId + "%")) - .containsExactly("UNKNOWN_REQUEST", "ID_MISMATCH", "INVALID_BODY", "INVALID_BODY", "INVALID_BODY"); - VideoAnalysisEntity untouched = analysisRepository.findById(pending.getAnalysisId()).orElseThrow(); - assertThat(untouched.getStatus()).isEqualTo(VideoAnalysisStatus.PENDING); - assertThat(untouched.getResult()).isNull(); - } - - @Test - void 결과가_먼저_들어오면_실패로_닫는_갱신이_덮지_않는다() { - VideoAnalysisEntity pending = analysisRepository.save( - row(video(interview, LocalDateTime.now()), "chain-a", VideoAnalysisStatus.PENDING, null)); - callbackHandler.handle(callback(pending, "job-" + sessionId, "ready", "")); - - int updated = analysisRepository.fail(pending.getAnalysisId(), "PROCESSING_OVERDUE", null, "늦음", LocalDateTime.now()); - - assertThat(updated).isZero(); - assertThat(analysisRepository.findById(pending.getAnalysisId()).orElseThrow().getStatus()) - .isEqualTo(VideoAnalysisStatus.READY); - } + // ---- 스윕이 고르는 쿼리 ---- @Test void 처음_요청할_영상은_조용해진_최종_영상이고_요청_기록이_없는_것뿐이다() { InterviewRecordingEntity older = video(interview, LocalDateTime.now().minusMinutes(10)); InterviewRecordingEntity latest = video(interview, LocalDateTime.now().minusMinutes(3)); - InterviewEntity busy = otherInterview(); + InterviewEntity busy = otherInterview(Status.COMPLETED, null); InterviewRecordingEntity justUploaded = video(busy, LocalDateTime.now().minusSeconds(30)); - InterviewEntity done = otherInterview(); + InterviewEntity done = otherInterview(Status.COMPLETED, null); InterviewRecordingEntity alreadyRequested = video(done, LocalDateTime.now().minusMinutes(10)); analysisRepository.save(row(alreadyRequested, "chain-x", VideoAnalysisStatus.READY, null)); - List picked = toStart(); - - assertThat(picked).contains(latest.getRecordingId()) + assertThat(toStart()).contains(latest.getRecordingId()) .doesNotContain(older.getRecordingId(), justUploaded.getRecordingId(), alreadyRequested.getRecordingId()); } + /** 영상은 면접 상태와 상관없이 받아 두지만, 분석은 정상 완료된 음성 면접만 한다. */ + @Test + void 중단_진행_중_텍스트_면접의_영상은_처음_요청할_영상으로_고르지_않는다() { + InterviewRecordingEntity completed = video(interview, LocalDateTime.now().minusMinutes(10)); + InterviewRecordingEntity abandoned = video(otherInterview(Status.ABANDONED, null), LocalDateTime.now().minusMinutes(10)); + InterviewEntity running = otherInterview(Status.IN_PROGRESS, AnswerMode.VOICE); + InterviewRecordingEntity inProgress = video(running, LocalDateTime.now().minusMinutes(10)); + InterviewRecordingEntity text = video(otherInterview(Status.COMPLETED, AnswerMode.TEXT), LocalDateTime.now().minusMinutes(10)); + + assertThat(toStart()).contains(completed.getRecordingId()) + .doesNotContain(abandoned.getRecordingId(), inProgress.getRecordingId(), text.getRecordingId()); + + // 진행 중에 먼저 올라온 영상은 버리지 않는다. 완료로 확정되면 고른다. + jdbc.update("update interview set status = 'COMPLETED' where interview_id = ?", running.getInterviewId()); + assertThat(toStart()).contains(inProgress.getRecordingId()); + } + /** 스윕은 분석을 켠 시각을 uploadedAfter로 넘긴다. 그 시각까지 올라온 영상은 고르지 않는다. */ @Test void 분석을_켜기_전에_올라온_영상은_처음_요청할_영상으로_고르지_않는다() { LocalDateTime enabledAt = LocalDateTime.now().minusHours(1); InterviewRecordingEntity before = video(interview, enabledAt.minusMinutes(1)); - InterviewEntity later = otherInterview(); + InterviewEntity later = otherInterview(Status.COMPLETED, null); InterviewRecordingEntity after = video(later, enabledAt.plusMinutes(1)); LocalDateTime now = LocalDateTime.now(); @@ -224,7 +219,7 @@ void tearDown() { assertThat(candidates()).isEmpty(); // 새 영상이 올라오면 옛 영상의 실패는 잇지 않는다(새 영상이 따로 요청된다). - InterviewEntity other = otherInterview(); + InterviewEntity other = otherInterview(Status.COMPLETED, null); InterviewRecordingEntity replaced = video(other, LocalDateTime.now().minusHours(1)); analysisRepository.save(row(replaced, "chain-" + UUID.randomUUID(), VideoAnalysisStatus.FAILED, true)); video(other, LocalDateTime.now().minusMinutes(30)); @@ -233,7 +228,7 @@ void tearDown() { } @Test - void 재시도_불가이거나_부분_결과인_요청은_자동으로_잇지_않는다() { + void 재시도_불가이거나_부분_결과이거나_중단된_면접의_요청은_자동으로_잇지_않는다() { InterviewRecordingEntity video = video(interview, LocalDateTime.now().minusHours(1)); analysisRepository.save(row(video, "chain-" + UUID.randomUUID(), VideoAnalysisStatus.FAILED, false)); backdateUpdates(); @@ -242,133 +237,429 @@ void tearDown() { analysisRepository.save(row(video, "chain-" + UUID.randomUUID(), VideoAnalysisStatus.PARTIAL, true)); backdateUpdates(); assertThat(candidates()).isEmpty(); + + InterviewRecordingEntity abandoned = video(otherInterview(Status.ABANDONED, null), LocalDateTime.now().minusHours(1)); + analysisRepository.save(row(abandoned, "chain-" + UUID.randomUUID(), VideoAnalysisStatus.FAILED, true)); + backdateUpdates(); + assertThat(candidates()).isEmpty(); } - // ---- 분석 서버 계약 예시(repit-analysis-server-v2 docs/video-api.md)를 그대로 넣어 본다 ---- + // ---- 결과 저장(계약 예제를 그대로 넣는다) ---- + + @Test + void 분석_완료_결과는_jsonb로_READY로_남고_같은_문서의_재도착은_한_행이다() { + VideoAnalysisEntity pending = pendingRow(); + String body = callback("callback-ready", pending, "job-" + UUID.randomUUID()); + + assertThat(callbackHandler.handle(body)).isEqualTo(VideoAnalysisCallbackHandler.Outcome.APPLIED); + assertThat(callbackHandler.handle(body)).isEqualTo(VideoAnalysisCallbackHandler.Outcome.DUPLICATE); + + VideoAnalysisEntity stored = reload(pending); + assertThat(stored.getStatus()).isEqualTo(VideoAnalysisStatus.READY); + assertThat(stored.getErrorCode()).isNull(); + @SuppressWarnings("unchecked") + Map analysis = (Map) ((Map) stored.getResult().get("result")).get("analysis"); + assertThat(analysis.get("data")).isInstanceOf(Map.class); + assertThat(analysisRepository.findAllByRecordingIdOrderByAnalysisIdAsc(pending.getRecordingId())).hasSize(1); + assertThat(unmatchedCount()).isZero(); + } @Test void 분석기_미연결_결과는_분석_오류를_남긴_UNAVAILABLE이다() { VideoAnalysisEntity pending = pendingRow(); - callbackHandler.handle(example(CALLBACK_UNCONFIGURED, pending, "job-" + UUID.randomUUID())); + callbackHandler.handle(callback("callback-unconfigured", pending, "job-" + UUID.randomUUID())); VideoAnalysisEntity stored = reload(pending); assertThat(stored.getStatus()).isEqualTo(VideoAnalysisStatus.UNAVAILABLE); assertThat(stored.getErrorCode()).isEqualTo("ANALYZER_NOT_CONFIGURED"); assertThat(stored.getErrorRetryable()).isFalse(); - // 웹은 result.result.analysis를 읽는다. 본문 전체를 그대로 둔다. assertThat(stored.getResult()).containsKey("result").containsEntry("status", "unavailable"); assertThat(unmatchedCount()).isZero(); } @Test - void 파일_문제_결과는_파일_오류를_남긴_UNAVAILABLE이다() { + void 측정_불가는_재시도하지_않는_UNAVAILABLE이다() { VideoAnalysisEntity pending = pendingRow(); - callbackHandler.handle(example(CALLBACK_FILE_ERROR, pending, "job-" + UUID.randomUUID())); + callbackHandler.handle(callback("callback-unavailable", pending, "job-" + UUID.randomUUID())); + backdateUpdates(); VideoAnalysisEntity stored = reload(pending); - assertThat(stored.getStatus()).isEqualTo(VideoAnalysisStatus.UNAVAILABLE); - assertThat(stored.getErrorCode()).isEqualTo("VIDEO_FORMAT_UNSUPPORTED"); + assertThat(stored.getErrorCode()).isEqualTo("BEHAVIOR_NOT_MEASURABLE"); assertThat(stored.getErrorRetryable()).isFalse(); - assertThat(unmatchedCount()).isZero(); + assertThat(candidates()).isEmpty(); + } + + @Test + void 재시도할_만한_파일_오류는_자동_재분석으로_잇는다() { + VideoAnalysisEntity pending = pendingRow(); + Map body = fixture("callback-unconfigured"); + Map error = Map.of("code", "SOURCE_DOWNLOAD_FAILED", "message", "영상 파일을 내려받지 못했습니다.", + "retryable", true); + result(body).put("durationMs", null); + result(body).put("error", error); + analysisOf(body).put("error", error); + + callbackHandler.handle(callback(body, pending, "job-" + UUID.randomUUID())); + backdateUpdates(); + + VideoAnalysisEntity stored = reload(pending); + assertThat(stored.getErrorCode()).isEqualTo("SOURCE_DOWNLOAD_FAILED"); + assertThat(stored.getErrorRetryable()).isTrue(); + assertThat(candidates()).extracting(VideoAnalysisEntity::getAnalysisId).containsExactly(pending.getAnalysisId()); } + /** failed 문서도 원문을 남긴다. 이 서버가 결과 없이 닫은 행과 가르는 단서다. 화면의 호환용 result에는 쓰지 않는다. */ @Test - void 문서_생성_실패는_결과_없이_FAILED로_닫는다() { + void 문서_생성_실패는_FAILED로_닫고_원문을_남긴다() { VideoAnalysisEntity pending = pendingRow(); - callbackHandler.handle(example(CALLBACK_FAILED, pending, "job-" + UUID.randomUUID())); + callbackHandler.handle(callback("callback-failed", pending, "job-" + UUID.randomUUID())); VideoAnalysisEntity stored = reload(pending); assertThat(stored.getStatus()).isEqualTo(VideoAnalysisStatus.FAILED); assertThat(stored.getErrorCode()).isEqualTo("INTERNAL_ERROR"); assertThat(stored.getErrorRetryable()).isFalse(); - assertThat(stored.getResult()).isNull(); + assertThat(stored.getResult()).containsEntry("status", "failed"); assertThat(unmatchedCount()).isZero(); } @Test - void 분석_완료_결과는_READY로_남는다() { + void 모르는_요청이나_식별자가_다르거나_계약에_맞지_않는_결과는_원문만_따로_남긴다() { + VideoAnalysisEntity pending = pendingRow(); + String jobId = "job-" + UUID.randomUUID(); + + Map unknown = parse(callback("callback-ready", pending, jobId)); + unknown.put("requestId", "video-unknown-" + sessionId); + callbackHandler.handle(JSON.writeValueAsString(unknown)); + Map otherVideo = parse(callback("callback-unconfigured", pending, jobId)); + result(otherVideo).put("videoId", String.valueOf(pending.getRecordingId() + 1)); + callbackHandler.handle(JSON.writeValueAsString(otherVideo)); + callbackHandler.handle(callback("callback-ready", pending, jobId).replace("\"ready\"", "\"done\"")); + callbackHandler.handle(callback("callback-ready", pending, jobId).replace("\"schemaVersion\":\"1\"", "\"schemaVersion\":\"2\"")); + // 계약 위반: ready인데 행동 데이터가 없다. + Map noData = parse(callback("callback-ready", pending, jobId)); + analysisOf(noData).put("data", null); + callbackHandler.handle(JSON.writeValueAsString(noData)); + callbackHandler.handle("not json " + sessionId); + + assertThat(jdbc.queryForList("select reason from unmatched_callback where payload like ? order by callback_id", + String.class, "%" + sessionId + "%")) + .containsExactly("UNKNOWN_REQUEST", "ID_MISMATCH", "INVALID_BODY", "INVALID_BODY", "INVALID_BODY", "INVALID_BODY"); + VideoAnalysisEntity untouched = reload(pending); + assertThat(untouched.getStatus()).isEqualTo(VideoAnalysisStatus.PENDING); + assertThat(untouched.getResult()).isNull(); + } + + /** PostgreSQL은 NUL을 받지 못한다. 식별자에 섞여 와도 행을 찾기 전에 따로 남겨 5xx 재전송 고리에 빠지지 않는다. */ + @Test + void 식별자에_NUL이_섞인_본문은_조회하기_전에_따로_남긴다() { VideoAnalysisEntity pending = pendingRow(); + Map body = parse(callback("callback-ready", pending, "job-" + UUID.randomUUID())); + body.put("requestId", pending.getRequestId() + "\u0000"); - callbackHandler.handle(example(CALLBACK_READY, pending, "job-" + UUID.randomUUID())); + assertThat(callbackHandler.handle(JSON.writeValueAsString(body))).isEqualTo(VideoAnalysisCallbackHandler.Outcome.INVALID); - VideoAnalysisEntity stored = reload(pending); - assertThat(stored.getStatus()).isEqualTo(VideoAnalysisStatus.READY); - assertThat(stored.getErrorCode()).isNull(); + assertThat(unmatchedCount()).isEqualTo(1); + assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.PENDING); + } + + @Test + void 작업_조회로_받은_결과는_콜백과_같게_저장된다() { + for (String name : List.of("callback-partial", "callback-unconfigured", "callback-failed")) { + VideoAnalysisEntity byCallback = pendingRow(); + callbackHandler.handle(callback(name, byCallback, "job-" + UUID.randomUUID())); + + VideoAnalysisEntity byJob = pendingRow("job-" + UUID.randomUUID()); + Map body = parse(callback(name, byJob, byJob.getJobId())); + when(aiServerClient.getVideoAnalysisJob(byJob.getJobId())).thenReturn(new VideoAnalysisJobResponse( + byJob.getJobId(), name.equals("callback-failed") ? "failed" : "completed", body, null)); + service.check(byJob); + + VideoAnalysisEntity expected = reload(byCallback); + VideoAnalysisEntity actual = reload(byJob); + assertThat(actual.getStatus()).as(name).isEqualTo(expected.getStatus()); + assertThat(actual.getErrorCode()).as(name).isEqualTo(expected.getErrorCode()); + assertThat(actual.getErrorRetryable()).as(name).isEqualTo(expected.getErrorRetryable()); + assertThat(actual.getResult() == null).as(name).isEqualTo(expected.getResult() == null); + } + assertThat(unmatchedCount()).isZero(); + } + + @Test + void 작업_조회_결과가_계약에_맞지_않으면_콜백처럼_따로_남기고_닫는다() { + VideoAnalysisEntity byJob = pendingRow("job-" + UUID.randomUUID()); + Map body = parse(callback("callback-unavailable", byJob, byJob.getJobId())); @SuppressWarnings("unchecked") - Map analysis = (Map) ((Map) stored.getResult().get("result")).get("analysis"); - assertThat(analysis.get("data")).isEqualTo(Map.of("gaze", 0.8)); + Map error = (Map) analysisOf(body).get("error"); + error.put("retryable", true); + when(aiServerClient.getVideoAnalysisJob(byJob.getJobId())) + .thenReturn(new VideoAnalysisJobResponse(byJob.getJobId(), "completed", body, null)); + + service.check(byJob); + + assertThat(reload(byJob).getStatus()).isEqualTo(VideoAnalysisStatus.FAILED); + assertThat(reload(byJob).getErrorCode()).isEqualTo("INVALID_RESULT"); + assertThat(unmatchedCount()).isEqualTo(1); } + // ---- 멱등성·늦은 결과 ---- + @Test - void 다른_녹화의_결과는_ID_MISMATCH로_따로_남기고_행은_그대로다() { + void 키_순서만_다른_같은_종료_문서는_중복이라_아무것도_바꾸지_않는다() { VideoAnalysisEntity pending = pendingRow(); + String jobId = "job-" + UUID.randomUUID(); + callbackHandler.handle(callback("callback-partial", pending, jobId)); + LocalDateTime updatedAt = reload(pending).getUpdatedAt(); - callbackHandler.handle(example(CALLBACK_UNCONFIGURED, pending, "job-" + UUID.randomUUID()) - .replace("\"videoId\": \"" + pending.getRecordingId() + "\"", "\"videoId\": \"" + (pending.getRecordingId() + 1) + "\"")); + Object reordered = reversed(parse(callback("callback-partial", pending, jobId))); - assertThat(jdbc.queryForList("select reason from unmatched_callback where payload like ?", - String.class, "%" + sessionId + "%")).containsExactly("ID_MISMATCH"); - VideoAnalysisEntity untouched = reload(pending); - assertThat(untouched.getStatus()).isEqualTo(VideoAnalysisStatus.PENDING); - assertThat(untouched.getResult()).isNull(); + assertThat(callbackHandler.handle(JSON.writeValueAsString(reordered))) + .isEqualTo(VideoAnalysisCallbackHandler.Outcome.DUPLICATE); + assertThat(reload(pending).getUpdatedAt()).isEqualTo(updatedAt); + assertThat(unmatchedCount()).isZero(); } + /** 먼저 저장한 결과가 이긴다. ready가 늦게 온 failed로 바뀌지 않고, 늦게 온 문서는 원인을 볼 수 있게 따로 남는다. */ @Test - void 재시도할_만한_파일_오류는_자동_재분석으로_잇는다() { + void 상충하는_종료_문서는_처음_것을_두고_따로_남긴다() { VideoAnalysisEntity pending = pendingRow(); + String jobId = "job-" + UUID.randomUUID(); + callbackHandler.handle(callback("callback-ready", pending, jobId)); - callbackHandler.handle(example(CALLBACK_FILE_ERROR, pending, "job-" + UUID.randomUUID()) - .replace("VIDEO_FORMAT_UNSUPPORTED", "SOURCE_DOWNLOAD_FAILED") - .replace("\"retryable\": false", "\"retryable\": true")); - backdateUpdates(); + assertThat(callbackHandler.handle(callback("callback-failed", pending, jobId))) + .isEqualTo(VideoAnalysisCallbackHandler.Outcome.CONFLICT); - VideoAnalysisEntity stored = reload(pending); - assertThat(stored.getErrorCode()).isEqualTo("SOURCE_DOWNLOAD_FAILED"); - assertThat(stored.getErrorRetryable()).isTrue(); - assertThat(candidates()).extracting(VideoAnalysisEntity::getAnalysisId).containsExactly(pending.getAnalysisId()); + assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.READY); + assertThat(jdbc.queryForList("select detail from unmatched_callback where payload like ?", + String.class, "%" + sessionId + "%")).singleElement().asString().contains("처음 저장한 결과를 유지"); } + /** 이 서버가 시간 초과로만 닫은 행에는 분석 서버의 종료 문서가 없다. 늦게 온 첫 결과는 받는다. */ @Test - void 작업_조회로_받은_결과는_콜백과_같게_저장된다() { - VideoAnalysisEntity unavailableByCallback = pendingRow(); - callbackHandler.handle(example(CALLBACK_UNCONFIGURED, unavailableByCallback, "job-" + UUID.randomUUID())); - VideoAnalysisEntity unavailableByJob = pendingRow("job-" + UUID.randomUUID()); - when(aiServerClient.getVideoAnalysisJob(unavailableByJob.getJobId())).thenReturn(new VideoAnalysisJobResponse( - unavailableByJob.getJobId(), "completed", - json(example(CALLBACK_UNCONFIGURED, unavailableByJob, unavailableByJob.getJobId())), null)); - service.check(unavailableByJob); - - VideoAnalysisEntity failedByCallback = pendingRow(); - callbackHandler.handle(example(CALLBACK_FAILED, failedByCallback, "job-" + UUID.randomUUID())); - // snapshot-failed: result는 실패 콜백 본문 전체, error는 그 error와 같다. - VideoAnalysisEntity failedByJob = pendingRow("job-" + UUID.randomUUID()); - Map failedBody = json(example(CALLBACK_FAILED, failedByJob, failedByJob.getJobId())); - @SuppressWarnings("unchecked") - Map failedError = (Map) failedBody.get("error"); - when(aiServerClient.getVideoAnalysisJob(failedByJob.getJobId())).thenReturn(new VideoAnalysisJobResponse( - failedByJob.getJobId(), "failed", failedBody, failedError)); - service.check(failedByJob); - - assertSameOutcome(reload(unavailableByJob), reload(unavailableByCallback)); - assertSameOutcome(reload(failedByJob), reload(failedByCallback)); - assertThat(reload(failedByJob).getStatus()).isEqualTo(VideoAnalysisStatus.FAILED); + void 결과_없이_닫은_요청에_늦게_온_첫_결과는_저장하고_그다음_다른_문서는_상충이다() { + VideoAnalysisEntity pending = pendingRow(); + analysisRepository.fail(pending.getAnalysisId(), "PROCESSING_OVERDUE", null, "늦음", LocalDateTime.now()); + String jobId = "job-" + UUID.randomUUID(); + + assertThat(callbackHandler.handle(callback("callback-partial", pending, jobId))) + .isEqualTo(VideoAnalysisCallbackHandler.Outcome.APPLIED); + assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.PARTIAL); + assertThat(reload(pending).getErrorCode()).isNull(); + + assertThat(callbackHandler.handle(callback("callback-unconfigured", pending, jobId))) + .isEqualTo(VideoAnalysisCallbackHandler.Outcome.CONFLICT); + assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.PARTIAL); + } + + /** 늦게 온 옛 요청의 결과는 그 행에만 남는다. 화면은 새 요청의 상태와 옛 결과를 함께 보여 준다. */ + @Test + void 옛_요청의_늦은_결과는_자기_행만_바꾸고_새_요청을_가리지_않는다() { + ReflectionTestUtils.setField(service, "enabled", true); + InterviewRecordingEntity video = video(interview, LocalDateTime.now().minusHours(1)); + VideoAnalysisEntity old = analysisRepository.save(row(video, "chain-a", VideoAnalysisStatus.PENDING, null)); + analysisRepository.fail(old.getAnalysisId(), "NOT_ACKNOWLEDGED", null, "접수 미확인", LocalDateTime.now()); + VideoAnalysisEntity latest = analysisRepository.save(row(video, "chain-b", VideoAnalysisStatus.PENDING, null)); + + callbackHandler.handle(callback("callback-partial", old, "job-" + UUID.randomUUID())); + + assertThat(reload(old).getStatus()).isEqualTo(VideoAnalysisStatus.PARTIAL); + assertThat(reload(latest).getStatus()).isEqualTo(VideoAnalysisStatus.PENDING); + assertThat(reload(latest).getResult()).isNull(); + VideoAnalysisResponse view = service.get(interview.getUserId(), interview.getInterviewId()); + assertThat(view.status()).isEqualTo(VideoAnalysisResponse.Status.ANALYZING); + assertThat(view.latestAnalysisId()).isEqualTo(String.valueOf(latest.getAnalysisId())); + assertThat(view.behaviorAnalysisId()).isEqualTo(String.valueOf(old.getAnalysisId())); + assertThat(view.showingPreviousResult()).isTrue(); + } + + @Test + void 콜백이_먼저_오면_뒤늦은_접수_응답이_작업_id를_바꾸지_않는다() { + VideoAnalysisEntity pending = pendingRow(); + String jobId = "job-" + UUID.randomUUID(); + callbackHandler.handle(callback("callback-ready", pending, jobId)); + + int updated = analysisRepository.acknowledge(pending.getAnalysisId(), "job-other-" + sessionId, + LocalDateTime.now().plusMinutes(10), LocalDateTime.now()); + + assertThat(updated).isZero(); + assertThat(reload(pending).getJobId()).isEqualTo(jobId); + assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.READY); + } + + @Test + void 결과가_먼저_들어오면_실패로_닫는_갱신이_덮지_않는다() { + VideoAnalysisEntity pending = pendingRow(); + callbackHandler.handle(callback("callback-ready", pending, "job-" + UUID.randomUUID())); + + int updated = analysisRepository.fail(pending.getAnalysisId(), "PROCESSING_OVERDUE", null, "늦음", LocalDateTime.now()); + + assertThat(updated).isZero(); + assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.READY); + } + + // ---- 두 연결의 경쟁 ---- + + /** + * 한 연결이 요청 행을 잠근 채 결과를 저장하는 동안 다른 연결이 상충하는 결과를 보내면, 뒤 연결은 잠금이 풀릴 때까지 + * 기다렸다가 저장된 문서를 보고 상충으로 따로 남긴다. 읽은 값으로 덮어쓰는 마지막 쓰기 승리가 일어나지 않는다. + */ + @Test + void 두_연결이_상충하는_결과를_겹쳐_보내도_먼저_잠근_쪽만_반영한다() throws Exception { + VideoAnalysisEntity pending = pendingRow(); + String jobId = "job-" + UUID.randomUUID(); + String ready = callback("callback-ready", pending, jobId); + String failed = callback("callback-failed", pending, jobId); + CountDownLatch locked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + CompletableFuture first = CompletableFuture.supplyAsync(() -> + transactionTemplate.execute(tx -> { + analysisRepository.findLockedByRequestId(pending.getRequestId()).orElseThrow(); + locked.countDown(); + await(release); + return callbackHandler.handle(ready); + })); + assertThat(locked.await(10, TimeUnit.SECONDS)).isTrue(); + CompletableFuture second = + CompletableFuture.supplyAsync(() -> callbackHandler.handle(failed)); + + // 뒤 연결은 행 잠금에서 기다린다. + Thread.sleep(500); + assertThat(second).isNotDone(); + release.countDown(); + + assertThat(first.get(10, TimeUnit.SECONDS)).isEqualTo(VideoAnalysisCallbackHandler.Outcome.APPLIED); + assertThat(second.get(10, TimeUnit.SECONDS)).isEqualTo(VideoAnalysisCallbackHandler.Outcome.CONFLICT); + assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.READY); + assertThat(unmatchedCount()).isEqualTo(1); + } + + /** + * 한 탭의 재분석이 면접 행을 잠근 채 사용자 묶음을 하나 더 만드는 동안 다른 탭이 누르면, 뒤 탭은 잠금이 풀린 뒤 늘어난 + * 사용자 재분석 수를 보고 한도로 거절한다. 만든 행이 진행 중(PENDING)이 아니어도 막는다 — 진행 중 요청 하나라는 DB + * 인덱스로는 잡히지 않는 경쟁이다. + */ + @Test + void 재분석_한도는_면접_잠금_아래에서_다시_세어_인덱스가_못_잡는_경쟁도_막는다() throws Exception { + ReflectionTestUtils.setField(service, "enabled", true); + InterviewRecordingEntity video = video(interview, LocalDateTime.now().minusHours(1)); + VideoAnalysisEntity firstUser = row(video, "chain-u1", VideoAnalysisStatus.FAILED, null); + firstUser.setRequestedBy(VideoAnalysisRequestedBy.USER); + analysisRepository.save(firstUser); + CountDownLatch locked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + CompletableFuture otherTab = CompletableFuture.runAsync(() -> transactionTemplate.executeWithoutResult(tx -> { + interviewRepository.lockById(interview.getInterviewId()).orElseThrow(); + // 다른 탭이 두 번째 사용자 묶음을 만들었고, 그 요청은 곧바로 결과 없이 닫혔다. + VideoAnalysisEntity secondUser = row(video, "chain-u2", VideoAnalysisStatus.FAILED, null); + secondUser.setRequestedBy(VideoAnalysisRequestedBy.USER); + analysisRepository.save(secondUser); + locked.countDown(); + await(release); + })); + assertThat(locked.await(10, TimeUnit.SECONDS)).isTrue(); + CompletableFuture thisTab = + CompletableFuture.supplyAsync(() -> service.retry(interview.getUserId(), interview.getInterviewId())); + + Thread.sleep(500); + assertThat(thisTab).isNotDone(); + release.countDown(); + otherTab.get(10, TimeUnit.SECONDS); + + assertThatThrownBy(() -> thisTab.get(10, TimeUnit.SECONDS)) + .hasCauseInstanceOf(BusinessException.class) + .hasMessageContaining("2번까지"); + assertThat(analysisRepository.findAllByRecordingIdOrderByAnalysisIdAsc(video.getRecordingId())) + .extracting(VideoAnalysisEntity::getChainId).containsExactly("chain-u1", "chain-u2"); + } + + @Test + void 두_탭이_동시에_재분석을_눌러도_한_번만_받는다() throws Exception { + ReflectionTestUtils.setField(service, "enabled", true); + InterviewRecordingEntity video = video(interview, LocalDateTime.now().minusHours(1)); + analysisRepository.save(row(video, "chain-a", VideoAnalysisStatus.PARTIAL, null)); + CyclicBarrier together = new CyclicBarrier(2); + + List> tabs = List.of(1, 2).stream() + .map(i -> CompletableFuture.supplyAsync(() -> { + await(together); + try { + return (Object) service.retry(interview.getUserId(), interview.getInterviewId()); + } catch (BusinessException e) { + return e; + } + })) + .toList(); + List results = new ArrayList<>(); + for (CompletableFuture tab : tabs) { + results.add(tab.get(20, TimeUnit.SECONDS)); + } + + assertThat(results).filteredOn(VideoAnalysisResponse.class::isInstance).hasSize(1); + assertThat(results).filteredOn(BusinessException.class::isInstance).singleElement() + .satisfies(e -> assertThat(((BusinessException) e).getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(analysisRepository.findAllByRecordingIdOrderByAnalysisIdAsc(video.getRecordingId())) + .filteredOn(analysis -> analysis.getRequestedBy() == VideoAnalysisRequestedBy.USER).hasSize(1); + } + + // ---- 면접 자격 ---- + + /** 중단 면접의 늦은 결과는 받되(2xx) 반영하지 않고, 기다리던 요청은 정책 사유로 닫는다. */ + @Test + void 중단된_면접의_늦은_콜백은_반영하지_않고_대기_요청을_닫는다() { + VideoAnalysisEntity pending = pendingRow(); + jdbc.update("update interview set status = 'ABANDONED' where interview_id = ?", interview.getInterviewId()); + + assertThat(callbackHandler.handle(callback("callback-ready", pending, "job-" + UUID.randomUUID()))) + .isEqualTo(VideoAnalysisCallbackHandler.Outcome.POLICY_CLOSED); + + VideoAnalysisEntity stored = reload(pending); + assertThat(stored.getStatus()).isEqualTo(VideoAnalysisStatus.FAILED); + assertThat(stored.getErrorCode()).isEqualTo("INTERVIEW_ABANDONED"); + assertThat(stored.getErrorRetryable()).isFalse(); + assertThat(stored.getResult()).isNull(); assertThat(unmatchedCount()).isZero(); } - private static void assertSameOutcome(VideoAnalysisEntity actual, VideoAnalysisEntity expected) { - assertThat(actual.getStatus()).isEqualTo(expected.getStatus()); - assertThat(actual.getErrorCode()).isEqualTo(expected.getErrorCode()); - assertThat(actual.getErrorRetryable()).isEqualTo(expected.getErrorRetryable()); - assertThat(actual.getErrorMessage()).isEqualTo(expected.getErrorMessage()); - assertThat(actual.getResult() == null).isEqualTo(expected.getResult() == null); + @Test + void 진행_중인_면접의_콜백은_반영하지도_닫지도_않는다() { + VideoAnalysisEntity pending = pendingRow(); + jdbc.update("update interview set status = 'IN_PROGRESS' where interview_id = ?", interview.getInterviewId()); + + assertThat(callbackHandler.handle(callback("callback-ready", pending, "job-" + UUID.randomUUID()))) + .isEqualTo(VideoAnalysisCallbackHandler.Outcome.POLICY_HELD); + + assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.PENDING); + assertThat(reload(pending).getResult()).isNull(); + } + + // ---- 기능 스위치 ---- + + /** 꺼도 맡긴 요청은 같은 요청 id로 작업을 조회해 결과를 되찾는다. 새 요청 행은 만들지 않는다. */ + @Test + void 꺼져_있어도_맡긴_요청은_작업_조회로_복구하고_새_요청은_만들지_않는다() { + VideoAnalysisEntity pending = pendingRow("job-" + UUID.randomUUID()); + jdbc.update("update video_analysis set next_check_at = ? where analysis_id = ?", + LocalDateTime.now().minusMinutes(1), pending.getAnalysisId()); + video(interview, LocalDateTime.now().minusMinutes(10)); + Map body = parse(callback("callback-partial", pending, pending.getJobId())); + when(aiServerClient.getVideoAnalysisJob(pending.getJobId())) + .thenReturn(new VideoAnalysisJobResponse(pending.getJobId(), "completed", body, null)); + ReflectionTestUtils.setField(service, "videoApiToken", "video-secret"); + + service.sweep(); + + assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.PARTIAL); + assertThat(jdbc.queryForObject("select count(*) from video_analysis where interview_id = ?", Integer.class, + interview.getInterviewId())).isEqualTo(1); + verify(aiServerClient, never()).requestVideoAnalysis(any()); } // ---- 준비 ---- - /** 그 면접의 새 영상에 결과를 기다리는 요청 하나. */ private VideoAnalysisEntity pendingRow() { return pendingRow(null); } @@ -384,103 +675,67 @@ private VideoAnalysisEntity reload(VideoAnalysisEntity analysis) { return analysisRepository.findById(analysis.getAnalysisId()).orElseThrow(); } - /** 계약 예시의 식별자를 이 요청의 값으로 바꾼다. 나머지는 그대로다. */ - private static String example(String body, VideoAnalysisEntity analysis, String jobId) { - return body.replace("\"9f0c1e\"", "\"" + jobId + "\"") - .replace("\"video-request-001\"", "\"" + analysis.getRequestId() + "\"") - .replace("\"b1c2d3\"", "\"" + analysis.getSessionId() + "\"") - .replace("\"interviewId\": \"42\"", "\"interviewId\": \"" + analysis.getInterviewId() + "\"") - .replace("\"userId\": \"7\"", "\"userId\": \"" + analysis.getUserId() + "\"") - .replace("\"videoId\": \"501\"", "\"videoId\": \"" + analysis.getRecordingId() + "\""); + /** 계약 예제의 식별자를 이 요청의 값으로 바꾼다. 행동 데이터의 videoId도 같이 바꾼다. 나머지는 그대로다. */ + private static String callback(String fixture, VideoAnalysisEntity analysis, String jobId) { + return callback(fixture(fixture), analysis, jobId); } - private static Map json(String body) { - return JsonMapper.builder().build().readValue(body, new TypeReference<>() { + private static String callback(Map body, VideoAnalysisEntity analysis, String jobId) { + body.put("jobId", jobId); + body.put("requestId", analysis.getRequestId()); + body.put("sessionId", analysis.getSessionId()); + body.put("interviewId", String.valueOf(analysis.getInterviewId())); + body.put("userId", String.valueOf(analysis.getUserId())); + if (body.get("result") instanceof Map) { + result(body).put("videoId", String.valueOf(analysis.getRecordingId())); + if (analysisOf(body).get("data") instanceof Map data) { + @SuppressWarnings("unchecked") + Map behavior = (Map) data; + behavior.put("videoId", String.valueOf(analysis.getRecordingId())); + } + } + return JSON.writeValueAsString(body); + } + + private static Map fixture(String name) { + try (InputStream in = VideoAnalysisPersistenceTest.class.getResourceAsStream("/contracts/behavior/" + name + ".json")) { + return JSON.readValue(in, new TypeReference>() { + }); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private static Map parse(String body) { + return JSON.readValue(body, new TypeReference>() { }); } - private static final String CALLBACK_UNCONFIGURED = """ - { - "schemaVersion": "1", - "jobId": "9f0c1e", - "requestId": "video-request-001", - "sessionId": "b1c2d3", - "interviewId": "42", - "userId": "7", - "status": "unavailable", - "result": { - "videoId": "501", - "durationMs": 734120, - "status": "unavailable", - "analysis": { - "status": "unavailable", - "data": null, - "error": {"code": "ANALYZER_NOT_CONFIGURED", "message": "영상 행동 분석기가 아직 연결되지 않았습니다.", "retryable": false} - }, - "error": null - }, - "error": null - } - """; - - private static final String CALLBACK_FILE_ERROR = """ - { - "schemaVersion": "1", - "jobId": "9f0c1e", - "requestId": "video-request-001", - "sessionId": "b1c2d3", - "interviewId": "42", - "userId": "7", - "status": "unavailable", - "result": { - "videoId": "501", - "durationMs": null, - "status": "unavailable", - "analysis": { - "status": "unavailable", - "data": null, - "error": {"code": "VIDEO_FORMAT_UNSUPPORTED", "message": "지원하지 않는 영상 형식입니다. WebM(VP8/VP9) 또는 MP4(H.264)만 지원합니다.", "retryable": false} - }, - "error": {"code": "VIDEO_FORMAT_UNSUPPORTED", "message": "지원하지 않는 영상 형식입니다. WebM(VP8/VP9) 또는 MP4(H.264)만 지원합니다.", "retryable": false} - }, - "error": null - } - """; - - private static final String CALLBACK_FAILED = """ - { - "schemaVersion": "1", - "jobId": "9f0c1e", - "requestId": "video-request-001", - "sessionId": "b1c2d3", - "interviewId": "42", - "userId": "7", - "status": "failed", - "result": null, - "error": {"code": "INTERNAL_ERROR", "message": "영상 분석 서버 내부 오류가 발생했습니다.", "retryable": false} - } - """; - - // 계약에 ready 예시는 없다(분석기 미연결). 미연결 예시에서 상태와 analysis만 바꿨다. data 모양은 아직 정해지지 않았다. - private static final String CALLBACK_READY = """ - { - "schemaVersion": "1", - "jobId": "9f0c1e", - "requestId": "video-request-001", - "sessionId": "b1c2d3", - "interviewId": "42", - "userId": "7", - "status": "ready", - "result": { - "videoId": "501", - "durationMs": 734120, - "status": "ready", - "analysis": {"status": "ready", "data": {"gaze": 0.8}, "error": null}, - "error": null - }, - "error": null + @SuppressWarnings("unchecked") + private static Map result(Map body) { + return (Map) body.get("result"); + } + + @SuppressWarnings("unchecked") + private static Map analysisOf(Map body) { + return (Map) result(body).get("analysis"); + } + + /** 객체의 키 순서를 모두 뒤집는다. 배열의 순서는 그대로 둔다. */ + private static Object reversed(Object value) { + if (value instanceof Map map) { + List> entries = new ArrayList<>(map.entrySet()); + Map out = new LinkedHashMap<>(); + for (int i = entries.size() - 1; i >= 0; i--) { + out.put(entries.get(i).getKey(), reversed(entries.get(i).getValue())); } - """; + return out; + } + if (value instanceof List list) { + return list.stream().map(VideoAnalysisPersistenceTest::reversed).toList(); + } + return value; + } private List toStart() { LocalDateTime now = LocalDateTime.now(); @@ -504,9 +759,9 @@ private void backdateUpdates() { } } - private InterviewEntity otherInterview() { + private InterviewEntity otherInterview(Status status, AnswerMode answerMode) { InterviewEntity other = interviewRepository.save(InterviewEntity.builder() - .userId(77L).sessionId("s-" + UUID.randomUUID()).status(Status.COMPLETED).build()); + .userId(77L).sessionId("s-" + UUID.randomUUID()).status(status).answerMode(answerMode).build()); interviewIds.add(other.getInterviewId()); return other; } @@ -530,15 +785,25 @@ private VideoAnalysisEntity row(InterviewRecordingEntity video, String chainId, .build(); } - private String callback(VideoAnalysisEntity analysis, String jobId, String status, String extra) { - return "{\"jobId\":\"" + jobId + "\",\"requestId\":\"" + analysis.getRequestId() + "\"" - + ",\"sessionId\":\"" + sessionId + "\",\"interviewId\":\"" + analysis.getInterviewId() + "\"" - + ",\"userId\":\"" + analysis.getUserId() + "\",\"result\":{\"videoId\":\"" + analysis.getRecordingId() + "\"}" - + ",\"schemaVersion\":\"1\",\"status\":\"" + status + "\"" + extra + "}"; - } - private int unmatchedCount() { return jdbc.queryForObject("select count(*) from unmatched_callback where payload like ?", Integer.class, "%" + sessionId + "%"); } + + private static void await(CountDownLatch latch) { + try { + latch.await(20, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static void await(CyclicBarrier barrier) { + try { + barrier.await(20, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java index 2a3935bb..dcedd710 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java @@ -19,6 +19,8 @@ import repit.repit_api_server.domain.userdata.analysis.entity.enums.VideoAnalysisStatus; import repit.repit_api_server.domain.userdata.analysis.repository.VideoAnalysisRepository; import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode; +import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; import repit.repit_api_server.domain.userdata.recording.entity.InterviewRecordingEntity; import repit.repit_api_server.domain.userdata.recording.entity.enums.RecordingKind; @@ -30,6 +32,9 @@ import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; import tools.jackson.databind.json.JsonMapper; import java.time.LocalDateTime; @@ -74,6 +79,8 @@ class VideoAnalysisServiceTest { private VideoAnalysisCallbackHandler callbackHandler; @Mock private AiServerClient aiServerClient; + @Mock + private PlatformTransactionManager transactionManager; private S3Presigner presigner; private VideoAnalysisService service; @@ -88,8 +95,11 @@ void setUp() { .region(Region.AP_NORTHEAST_2) .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create("test", "test"))) .build(); + // 잠금·트랜잭션은 영속성 테스트가 실제 DB로 본다. 여기서는 트랜잭션 경계만 흉내 낸다. + when(transactionManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus()); service = new VideoAnalysisService(analysisRepository, interviewRepository, recordingRepository, - callbackHandler, aiServerClient, presigner, JsonMapper.builder().build()); + callbackHandler, aiServerClient, presigner, JsonMapper.builder().build(), + new TransactionTemplate(transactionManager)); ReflectionTestUtils.setField(service, "enabled", true); ReflectionTestUtils.setField(service, "internalAuthToken", "secret"); ReflectionTestUtils.setField(service, "videoApiToken", "video-secret"); @@ -99,6 +109,7 @@ void setUp() { ReflectionTestUtils.setField(service, "bucketName", "repit-bucket"); when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(interview("b1c2d3"))); + when(interviewRepository.lockById(INTERVIEW_ID)).thenAnswer(i -> interviewRepository.findById(INTERVIEW_ID)); when(recordingRepository.findFirstByInterviewIdAndKindOrderByRecordingIdDesc(INTERVIEW_ID, RecordingKind.FULL_INTERVIEW)) .thenAnswer(i -> Optional.of(video(LocalDateTime.now().minusDays(1)))); when(analysisRepository.findAllByRecordingIdOrderByAnalysisIdAsc(RECORDING_ID)).thenAnswer(i -> List.copyOf(analyses)); @@ -310,12 +321,12 @@ void tearDown() { void 끝난_작업의_결과는_콜백과_같은_검증을_거쳐_저장하고_통과하지_못하면_닫는다() { Map result = Map.of("status", "ready"); when(aiServerClient.getVideoAnalysisJob("job-1")).thenReturn(new VideoAnalysisJobResponse("job-1", "completed", result, null)); - when(callbackHandler.applyRecovered(ANALYSIS_ID, result)).thenReturn(true); + when(callbackHandler.applyRecovered(ANALYSIS_ID, result)).thenReturn(VideoAnalysisCallbackHandler.Outcome.APPLIED); service.check(pending("job-1", 1, LocalDateTime.now().minusMinutes(30))); verify(analysisRepository, never()).fail(any(), any(), any(), any(), any()); - when(callbackHandler.applyRecovered(ANALYSIS_ID, result)).thenReturn(false); + when(callbackHandler.applyRecovered(ANALYSIS_ID, result)).thenReturn(VideoAnalysisCallbackHandler.Outcome.INVALID); service.check(pending("job-1", 1, LocalDateTime.now().minusMinutes(30))); verify(analysisRepository).fail(eq(ANALYSIS_ID), eq("INVALID_RESULT"), isNull(), anyString(), any()); } @@ -519,23 +530,68 @@ void tearDown() { assertThat(service.get(USER_ID, INTERVIEW_ID).status()).isEqualTo(VideoAnalysisResponse.Status.WAITING); } + /** 끄는 것은 새 작업을 막는 것이지 맡긴 작업을 버리는 것이 아니다. 끄는 순간 기다리던 요청이 영영 "분석 중"으로 남지 않게 한다. */ @Test - void 꺼져_있으면_스윕이_아무것도_하지_않는다() { + void 꺼져_있으면_새_요청과_자동_재분석은_만들지_않고_맡긴_요청만_끝까지_본다() { ReflectionTestUtils.setField(service, "enabled", false); + VideoAnalysisEntity pending = pending("job-1", 1, LocalDateTime.now().minusMinutes(30)); + when(analysisRepository.findDueChecks(any(), any())).thenReturn(List.of(pending)); + when(aiServerClient.getVideoAnalysisJob("job-1")).thenReturn(new VideoAnalysisJobResponse("job-1", "processing", null, null)); service.sweep(); verify(analysisRepository, never()).findVideosToStart(any(), any(), any()); - verify(analysisRepository, never()).findDueChecks(any(), any()); + verify(analysisRepository, never()).findReanalysisCandidates(any(), any(Long.class), any()); + verify(aiServerClient).getVideoAnalysisJob("job-1"); + verify(analysisRepository, never()).save(any()); + } + + @Test + void 꺼진_채_요청_인증값도_없으면_남은_요청을_실패로_닫지_않고_미룬다() { + ReflectionTestUtils.setField(service, "enabled", false); + ReflectionTestUtils.setField(service, "videoApiToken", ""); + when(analysisRepository.findDueChecks(any(), any())).thenReturn(List.of(pending(null, 0, LocalDateTime.now()))); + + service.sweep(); + + verify(aiServerClient, never()).requestVideoAnalysis(any()); + verify(analysisRepository, never()).fail(any(), any(), any(), any(), any()); } @Test - void 분석이_꺼져_있고_요청이_없는_영상은_WAITING이며_재분석할_수_없다() { + void 꺼져_있어도_맡긴_요청은_분석_중이고_저장된_결과는_보이되_재분석은_막힌다() { ReflectionTestUtils.setField(service, "enabled", false); + analyses.add(done(1L, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.PENDING, null, null)); + assertThat(service.get(USER_ID, INTERVIEW_ID).status()).isEqualTo(VideoAnalysisResponse.Status.ANALYZING); + analyses.clear(); + analyses.add(done(1L, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.PARTIAL, null, Map.of("v", 1))); + VideoAnalysisResponse partial = service.get(USER_ID, INTERVIEW_ID); + assertThat(partial.status()).isEqualTo(VideoAnalysisResponse.Status.PARTIAL); + assertThat(partial.canRetry()).isFalse(); + } + + /** 꺼져 있으면 자동 재분석을 만들지 않는다. "분석 중"으로 보이면 오지 않을 재분석을 기다린다. */ + @Test + void 꺼져_있으면_재시도할_만한_실패도_자동_재분석_대기가_아니라_실패다() { + analyses.add(done(1L, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.FAILED, true, null)); + assertThat(service.get(USER_ID, INTERVIEW_ID).status()).isEqualTo(VideoAnalysisResponse.Status.ANALYZING); + + ReflectionTestUtils.setField(service, "enabled", false); VideoAnalysisResponse response = service.get(USER_ID, INTERVIEW_ID); - assertThat(response.status()).isEqualTo(VideoAnalysisResponse.Status.WAITING); + assertThat(response.status()).isEqualTo(VideoAnalysisResponse.Status.FAILED); + assertThat(response.canRetry()).isFalse(); + } + + @Test + void 분석이_꺼져_있고_요청이_없는_영상은_NONE이며_재분석할_수_없다() { + ReflectionTestUtils.setField(service, "enabled", false); + + VideoAnalysisResponse response = service.get(USER_ID, INTERVIEW_ID); + + assertThat(response.status()).isEqualTo(VideoAnalysisResponse.Status.NONE); + assertThat(response.reasonCode()).isEqualTo("SERVICE_DISABLED"); assertThat(response.result()).isNull(); assertThat(response.canRetry()).isFalse(); assertThatThrownBy(() -> service.retry(USER_ID, INTERVIEW_ID)) @@ -545,10 +601,152 @@ void tearDown() { verify(aiServerClient, never()).requestVideoAnalysis(any()); } + // ---- 면접 자격 ---- + + @Test + void 정상_완료되지_않은_면접은_조회와_재분석이_409다() { + for (Status status : List.of(Status.ABANDONED, Status.IN_PROGRESS)) { + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(interview("b1c2d3", status, null))); + + assertThatThrownBy(() -> service.get(USER_ID, INTERVIEW_ID)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThatThrownBy(() -> service.retry(USER_ID, INTERVIEW_ID)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + } + verify(analysisRepository, never()).save(any()); + } + + /** 권한·존재 오류는 정책 409보다 먼저다. 남의 중단 면접에 409를 주면 그 면접의 상태가 드러난다. */ + @Test + void 남의_중단_면접은_409가_아니라_403이다() { + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(interview("b1c2d3", Status.ABANDONED, null))); + + assertThatThrownBy(() -> service.get(USER_ID + 1, INTERVIEW_ID)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); + } + + @Test + void 텍스트_면접은_녹화가_있어도_행동_분석을_보이지도_하지도_않는다() { + when(interviewRepository.findById(INTERVIEW_ID)) + .thenReturn(Optional.of(interview("b1c2d3", Status.COMPLETED, AnswerMode.TEXT))); + analyses.add(done(1L, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.PARTIAL, null, Map.of("v", 1))); + + VideoAnalysisResponse response = service.get(USER_ID, INTERVIEW_ID); + + assertThat(response.status()).isEqualTo(VideoAnalysisResponse.Status.NONE); + assertThat(response.reasonCode()).isEqualTo("TEXT_INTERVIEW"); + assertThat(response.result()).isNull(); + assertThat(response.behavior()).isNull(); + assertThatThrownBy(() -> service.retry(USER_ID, INTERVIEW_ID)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + } + + @Test + void 중단_면접의_대기_요청은_분석_서버를_부르지_않고_정책_사유로_닫는다() { + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(interview("b1c2d3", Status.ABANDONED, null))); + + service.check(pending(null, 0, LocalDateTime.now())); + service.check(pending("job-1", 1, LocalDateTime.now().minusMinutes(30))); + + verify(aiServerClient, never()).requestVideoAnalysis(any()); + verify(aiServerClient, never()).getVideoAnalysisJob(any()); + verify(analysisRepository, org.mockito.Mockito.times(2)) + .fail(eq(ANALYSIS_ID), eq("INTERVIEW_ABANDONED"), eq(false), anyString(), any()); + } + + /** 진행 중인 면접의 요청은 닫지 않는다. 완료로 확정되면 이어서 확인한다. */ + @Test + void 진행_중인_면접의_대기_요청은_닫지_않고_미룬다() { + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(interview("b1c2d3", Status.IN_PROGRESS, null))); + + service.check(pending("job-1", 1, LocalDateTime.now().minusHours(7))); + + verify(aiServerClient, never()).getVideoAnalysisJob(any()); + verify(analysisRepository, never()).fail(any(), any(), any(), any(), any()); + ArgumentCaptor next = ArgumentCaptor.forClass(LocalDateTime.class); + verify(analysisRepository).postpone(eq(ANALYSIS_ID), next.capture(), anyString(), any()); + assertThat(next.getValue()).isAfter(LocalDateTime.now().plusMinutes(9)); + } + + @Test + void 잠근_뒤_면접이_중단으로_확정됐으면_첫_요청을_만들지_않는다() { + when(interviewRepository.lockById(INTERVIEW_ID)).thenReturn(Optional.of(interview("b1c2d3", Status.ABANDONED, null))); + + assertThat(service.openFirst(video(LocalDateTime.now().minusMinutes(5)))).isNull(); + verify(analysisRepository, never()).save(any()); + } + + @Test + void 잠근_뒤_이미_요청이_있거나_새_영상이_올라왔으면_첫_요청을_만들지_않는다() { + analyses.add(done(1L, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.FAILED, null, null)); + assertThat(service.openFirst(video(LocalDateTime.now().minusMinutes(5)))).isNull(); + + analyses.clear(); + InterviewRecordingEntity stale = InterviewRecordingEntity.builder() + .recordingId(RECORDING_ID - 1).interviewId(INTERVIEW_ID).userId(USER_ID).kind(RecordingKind.FULL_INTERVIEW) + .contentType("video/webm").s3Key("k").fileSize(10L).createdAt(LocalDateTime.now().minusMinutes(5)).build(); + assertThat(service.openFirst(stale)).isNull(); + + assertThat(service.openFirst(video(LocalDateTime.now().minusMinutes(5)))).isNotNull(); + } + + @Test + void 결과_문서_없이_실패만_온_작업의_오류도_공개_오류_계약을_따른다() { + when(aiServerClient.getVideoAnalysisJob("job-1")).thenReturn(new VideoAnalysisJobResponse("job-1", "failed", null, + Map.of("code", "BEHAVIOR_NOT_MEASURABLE", "message", "부족", "retryable", "false"))); + + service.check(pending("job-1", 1, LocalDateTime.now().minusMinutes(30))); + + verify(analysisRepository).fail(eq(ANALYSIS_ID), eq("INVALID_RESULT"), isNull(), anyString(), any()); + } + + @Test + void 작업_조회의_failed_결과_문서는_콜백과_같은_검증으로_저장한다() { + Map result = Map.of("status", "failed"); + when(aiServerClient.getVideoAnalysisJob("job-1")).thenReturn(new VideoAnalysisJobResponse("job-1", "failed", result, + Map.of("code", "INTERNAL_ERROR", "message", "x", "retryable", false))); + when(callbackHandler.applyRecovered(ANALYSIS_ID, result)).thenReturn(VideoAnalysisCallbackHandler.Outcome.APPLIED); + + service.check(pending("job-1", 1, LocalDateTime.now().minusMinutes(30))); + + verify(callbackHandler).applyRecovered(ANALYSIS_ID, result); + verify(analysisRepository, never()).fail(any(), any(), any(), any(), any()); + } + + /** 다시 켜며 기준 시각을 옮겨도 이미 정상 대상으로 분석한 영상의 재분석까지 막지 않는다. */ + @Test + void 분석_기준_시각은_요청이_한_번도_없는_영상에만_적용한다() { + InterviewRecordingEntity video = video(LocalDateTime.now().minusDays(1)); + ReflectionTestUtils.setField(service, "analyzeUploadedAfter", LocalDateTime.now().minusHours(1)); + + assertThat(service.retryBlockedReason(video, List.of(), LocalDateTime.now())).contains("시작하기 전"); + assertThat(service.retryBlockedReason(video, List.of( + done(1L, "a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.PARTIAL, null, Map.of())), LocalDateTime.now())) + .isNull(); + } + + @Test + void 이미_마친_분석은_canRetry가_거짓이다() { + analyses.add(done(1L, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.READY, null, Map.of("v", 1))); + + VideoAnalysisResponse response = service.get(USER_ID, INTERVIEW_ID); + + assertThat(response.status()).isEqualTo(VideoAnalysisResponse.Status.READY); + assertThat(response.canRetry()).isFalse(); + // 행동 데이터로 읽을 수 없는 옛 결과다. 지어내지 않고 사유를 밝힌다. + assertThat(response.behavior()).isNull(); + assertThat(response.reasonCode()).isEqualTo("RESULT_SCHEMA_UNSUPPORTED"); + } + // ---- 준비 ---- private static InterviewEntity interview(String sessionId) { - return InterviewEntity.builder().interviewId(INTERVIEW_ID).userId(USER_ID).sessionId(sessionId).build(); + return interview(sessionId, Status.COMPLETED, null); + } + + private static InterviewEntity interview(String sessionId, Status status, AnswerMode answerMode) { + return InterviewEntity.builder().interviewId(INTERVIEW_ID).userId(USER_ID).sessionId(sessionId) + .status(status).answerMode(answerMode).build(); } private static InterviewRecordingEntity video(LocalDateTime uploadedAt) { diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisViewContractTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisViewContractTest.java new file mode 100644 index 00000000..68ccd55a --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisViewContractTest.java @@ -0,0 +1,235 @@ +package repit.repit_api_server.domain.userdata.analysis.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import repit.repit_api_server.domain.userdata.analysis.behavior.JsonValues; +import repit.repit_api_server.domain.userdata.analysis.entity.VideoAnalysisEntity; +import repit.repit_api_server.domain.userdata.analysis.entity.enums.VideoAnalysisRequestedBy; +import repit.repit_api_server.domain.userdata.analysis.entity.enums.VideoAnalysisStatus; +import repit.repit_api_server.domain.userdata.analysis.repository.VideoAnalysisRepository; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; +import repit.repit_api_server.domain.userdata.recording.entity.InterviewRecordingEntity; +import repit.repit_api_server.domain.userdata.recording.entity.enums.RecordingKind; +import repit.repit_api_server.domain.userdata.recording.repository.InterviewRecordingRepository; +import repit.repit_api_server.global.client.AiServerClient; +import repit.repit_api_server.global.common.ApiResponse; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * 웹에 내려주는 GET 응답이 Web PR #189의 API 계약 예제(contracts/api-*.json)와 같은지. + * + *

    각 예제의 서버 상태(요청 행·결과 원문·기능 스위치)를 만들고 실제 조회 응답을 {@code {success, data}}로 감싸 비교한다. + * 필드 이름·null 여부·이전 결과 표시·공개 오류·canRetry까지 같아야 한다. 만든 응답은 + * {@code build/behavior-contract/}에 남긴다 — 웹의 실제 파서로 다시 읽어 보는 데 쓴다. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class VideoAnalysisViewContractTest { + + private static final JsonMapper JSON = JsonMapper.builder().build(); + private static final Long USER_ID = 7L; + private static final Long INTERVIEW_ID = 42L; + private static final Long RECORDING_ID = 501L; + private static final Path OUTPUT = Path.of("build", "behavior-contract"); + + @Mock + private VideoAnalysisRepository analysisRepository; + @Mock + private InterviewRepository interviewRepository; + @Mock + private InterviewRecordingRepository recordingRepository; + @Mock + private VideoAnalysisCallbackHandler callbackHandler; + @Mock + private AiServerClient aiServerClient; + @Mock + private PlatformTransactionManager transactionManager; + + private S3Presigner presigner; + private VideoAnalysisService service; + private final List analyses = new ArrayList<>(); + private InterviewRecordingEntity video; + + @BeforeEach + void setUp() { + presigner = S3Presigner.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create("test", "test"))) + .build(); + when(transactionManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus()); + service = new VideoAnalysisService(analysisRepository, interviewRepository, recordingRepository, + callbackHandler, aiServerClient, presigner, JSON, new TransactionTemplate(transactionManager)); + ReflectionTestUtils.setField(service, "enabled", true); + ReflectionTestUtils.setField(service, "analyzeUploadedAfter", LocalDateTime.now().minusDays(30)); + + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(InterviewEntity.builder() + .interviewId(INTERVIEW_ID).userId(USER_ID).sessionId("session-42").status(Status.COMPLETED).build())); + video = InterviewRecordingEntity.builder() + .recordingId(RECORDING_ID).interviewId(INTERVIEW_ID).userId(USER_ID).kind(RecordingKind.FULL_INTERVIEW) + .contentType("video/webm").s3Key("interview-recordings/42/v.webm").fileSize(10_000_000L) + .createdAt(LocalDateTime.now().minusDays(1)).build(); + when(recordingRepository.findFirstByInterviewIdAndKindOrderByRecordingIdDesc(INTERVIEW_ID, RecordingKind.FULL_INTERVIEW)) + .thenAnswer(i -> Optional.ofNullable(video)); + when(analysisRepository.findAllByRecordingIdOrderByAnalysisIdAsc(RECORDING_ID)).thenAnswer(i -> List.copyOf(analyses)); + } + + @AfterEach + void tearDown() { + presigner.close(); + } + + @Test + void 정상_결과() { + analyses.add(row(101, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.READY, fixture("callback-ready"))); + assertMatches("api-ready"); + } + + @Test + void 부분_결과() { + analyses.add(row(101, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.PARTIAL, fixture("callback-partial"))); + assertMatches("api-partial"); + } + + @Test + void 얼굴_0퍼센트() { + analyses.add(row(101, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.PARTIAL, fixture("callback-zero-face"))); + assertMatches("api-zero-face"); + } + + @Test + void 재분석_중에는_이전_부분_결과를_보여_준다() { + analyses.add(row(101, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.PARTIAL, fixture("callback-partial"))); + analyses.add(row(102, "chain-b", VideoAnalysisRequestedBy.USER, VideoAnalysisStatus.PENDING, null)); + assertMatches("api-analyzing-previous"); + } + + /** 최신 요청 102는 자동 재분석 예산(묶음당 5개)을 다 쓰고 실패했다. 사용자 재분석은 한 번 남았고 101의 결과를 계속 보여 준다. */ + @Test + void 최신_요청이_실패해도_이전_결과를_보여_준다() { + for (long id = 97; id <= 100; id++) { + VideoAnalysisEntity failed = row(id, "chain-b", VideoAnalysisRequestedBy.USER, VideoAnalysisStatus.FAILED, null); + failed.setErrorCode("PROCESSING_OVERDUE"); + failed.setErrorRetryable(true); + analyses.add(failed); + } + analyses.add(row(101, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.PARTIAL, fixture("callback-partial"))); + Map timeout = obj(fixture("api-failed-previous").get("data")).get("result") instanceof Map m + ? copy(m) : null; + VideoAnalysisEntity latest = row(102, "chain-b", VideoAnalysisRequestedBy.USER, VideoAnalysisStatus.UNAVAILABLE, timeout); + latest.setErrorCode("PROCESSING_TIMEOUT"); + latest.setErrorRetryable(true); + analyses.add(latest); + assertMatches("api-failed-previous"); + } + + @Test + void 측정_불가() { + VideoAnalysisEntity unavailable = row(101, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.UNAVAILABLE, + fixture("callback-unavailable")); + unavailable.setErrorCode("BEHAVIOR_NOT_MEASURABLE"); + unavailable.setErrorRetryable(false); + analyses.add(unavailable); + assertMatches("api-unavailable"); + } + + @Test + void 기능이_꺼져_있고_요청이_없다() { + ReflectionTestUtils.setField(service, "enabled", false); + ReflectionTestUtils.setField(service, "analyzeUploadedAfter", null); + assertMatches("api-disabled"); + } + + @Test + void 녹화가_없다() { + video = null; + assertMatches("api-no-recording"); + } + + @Test + void 요청_전이다() { + assertMatches("api-waiting"); + } + + // ---- 도구 ---- + + private void assertMatches(String name) { + Map actual = JSON.convertValue(ApiResponse.success(service.get(USER_ID, INTERVIEW_ID)), + new TypeReference>() { + }); + Map expected = fixture(name); + write(name, actual); + assertThat(JsonValues.same(actual, expected)) + .as("%s%n실제: %s%n예제: %s", name, JSON.writeValueAsString(actual), JSON.writeValueAsString(expected)) + .isTrue(); + } + + private static void write(String name, Map actual) { + try { + Files.createDirectories(OUTPUT); + Files.writeString(OUTPUT.resolve(name + ".json"), JSON.writeValueAsString(actual)); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private VideoAnalysisEntity row(long id, String chainId, VideoAnalysisRequestedBy requestedBy, VideoAnalysisStatus status, + Map result) { + return VideoAnalysisEntity.builder() + .analysisId(id).interviewId(INTERVIEW_ID).userId(USER_ID).sessionId("session-42") + .recordingId(RECORDING_ID).requestId("video-request-" + id).chainId(chainId) + .requestedBy(requestedBy).status(status).result(result) + .createdAt(LocalDateTime.now().minusHours(1)) + .build(); + } + + private static Map fixture(String name) { + try (InputStream in = VideoAnalysisViewContractTest.class.getResourceAsStream("/contracts/behavior/" + name + ".json")) { + return JSON.readValue(in, new TypeReference>() { + }); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @SuppressWarnings("unchecked") + private static Map obj(Object value) { + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static Map copy(Map value) { + return new LinkedHashMap<>((Map) value); + } +} diff --git a/src/test/java/repit/repit_api_server/global/logging/RequestLoggingFilterTest.java b/src/test/java/repit/repit_api_server/global/logging/RequestLoggingFilterTest.java index 1f0f170c..2859dea5 100644 --- a/src/test/java/repit/repit_api_server/global/logging/RequestLoggingFilterTest.java +++ b/src/test/java/repit/repit_api_server/global/logging/RequestLoggingFilterTest.java @@ -115,6 +115,54 @@ class RequestLoggingFilterTest { assertThat(requestSeenByHandler.get()).isNotInstanceOf(CachedBodyHttpServletRequest.class); } + /** 영상 콜백 본문은 내부 인증을 통과한 뒤 컨트롤러가 상한까지만 읽는다. 필터가 먼저 읽거나 로그에 싣지 않는다. */ + @Test + void 영상_콜백_본문은_필터가_읽지도_남기지도_않는다() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/analyses/video/callback"); + request.setContentType(MediaType.APPLICATION_JSON_VALUE); + request.setContent("{\"summary\":[\"behavior-secret\"]}".getBytes(StandardCharsets.UTF_8)); + AtomicReference requestSeenByHandler = new AtomicReference<>(); + AtomicReference responseSeenByHandler = new AtomicReference<>(); + + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + requestSeenByHandler.set(req); + responseSeenByHandler.set(res); + }); + + assertThat(requestSeenByHandler.get()).isNotInstanceOf(CachedBodyHttpServletRequest.class); + assertThat(responseSeenByHandler.get()).isNotInstanceOf(ContentCachingResponseWrapper.class); + assertThat(capturedLogs.list).extracting(ILoggingEvent::getFormattedMessage) + .noneMatch(message -> message.contains("behavior-secret")); + } + + @Test + void 영상_분석_조회_응답의_행동_데이터는_로그에_싣지_않는다() throws Exception { + AtomicReference responseSeenByHandler = new AtomicReference<>(); + + filter.doFilter(new MockHttpServletRequest("GET", "/api/interviews/42/video-analysis"), new MockHttpServletResponse(), + (req, res) -> responseSeenByHandler.set(res)); + + assertThat(responseSeenByHandler.get()).isNotInstanceOf(ContentCachingResponseWrapper.class); + } + + /** 길이를 밝히지 않은 본문은 끝까지 읽어야 크기를 안다. 인증도 받기 전에 상한 없이 메모리에 올리지 않는다. */ + @Test + void 길이를_밝히지_않은_본문은_메모리에_올리지_않는다() throws Exception { + MockHttpServletRequest chunked = new MockHttpServletRequest("POST", "/api/feedbacks/callback") { + @Override + public long getContentLengthLong() { + return -1; + } + }; + chunked.setContentType(MediaType.APPLICATION_JSON_VALUE); + chunked.setContent("{}".getBytes(StandardCharsets.UTF_8)); + AtomicReference requestSeenByHandler = new AtomicReference<>(); + + filter.doFilter(chunked, new MockHttpServletResponse(), (req, res) -> requestSeenByHandler.set(req)); + + assertThat(requestSeenByHandler.get()).isNotInstanceOf(CachedBodyHttpServletRequest.class); + } + @Test void SSE_응답은_감싸지_않아_이벤트가_바로_흘러간다() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/ai/subscribe/job-1"); From 527a82c71a55c7db7c382a83467485a6f5caaf56 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Tue, 20 Oct 2026 21:48:27 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(analysis):=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=E2=80=94=20null=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20NPE,=20=EC=99=B8=EB=B6=80=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EB=A1=9C=EA=B9=85,=20retryable=EA=B3=BC?= =?UTF-8?q?=20canRetry=20=EB=B6=88=EC=9D=BC=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이벤트 type·sourceMetric이 null이면 불변 Map 조회에서 NPE로 500이 됐다. 문자열을 먼저 검사하고, 규칙이 못 다룬 모양도 계약 위반으로 돌려 따로 남긴다. - 외부 호출 로그가 /analysis/video 요청·작업 조회 응답 본문(행동 수치)을 남겼다. 이 경로는 본문을 생략한다. - INVALID_RESULT를 retryable=false로 남긴다. 화면의 error.retryable과 canRetry가 같은 판단(VideoPublicErrors.userRetryable)을 쓴다. Co-Authored-By: Claude Opus 5.5 --- .../analysis/behavior/BehaviorValidator.java | 9 ++-- .../analysis/behavior/VideoPublicErrors.java | 54 +++++++++++++------ .../behavior/VideoResultDocument.java | 12 +++++ .../service/VideoAnalysisService.java | 10 ++-- .../ExternalApiLoggingInterceptor.java | 23 +++++++- .../behavior/BehaviorContractTest.java | 24 ++++++++- .../service/VideoAnalysisPersistenceTest.java | 18 +++++++ .../service/VideoAnalysisServiceTest.java | 26 ++++++++- .../ExternalApiLoggingInterceptorTest.java | 18 +++++++ 9 files changed, 166 insertions(+), 28 deletions(-) diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorValidator.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorValidator.java index 3ad33877..2514d53d 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorValidator.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorValidator.java @@ -118,6 +118,8 @@ private static BehaviorData.Metric metric(Object raw, String path) { exactKeys(m, METRIC_FIELDS, path); Object rawStatus = m.get("status"); require(AVAILABLE.equals(rawStatus) || UNAVAILABLE.equals(rawStatus), path + ".status", "unknown status"); + require(m.get("unavailableReason") == null || m.get("unavailableReason") instanceof String, + path + ".unavailableReason", "string required"); Double value = m.get("value") == null ? null : number(m.get("value"), 0, 1, path + ".value"); int numerator = (int) integer(m.get("numeratorMs"), 0, MAX_DURATION_MS, path + ".numeratorMs"); int denominator = (int) integer(m.get("denominatorMs"), 0, MAX_DURATION_MS, path + ".denominatorMs"); @@ -154,13 +156,14 @@ private static BehaviorData.TimelineBin bin(Object raw, String path) { private static BehaviorData.Event event(Object raw, String path) { Map e = object(raw, path); exactKeys(e, EVENT_FIELDS, path); - Object type = e.get("type"); + // 불변 Map은 null 키·값을 조회하면 NPE를 던진다. 문자열인지 먼저 본다. + String type = text(e.get("type"), 40, path + ".type"); require(EVENT_METRICS.containsKey(type), path + ".type", "unknown event type"); - Object source = e.get("sourceMetric"); + String source = text(e.get("sourceMetric"), 40, path + ".sourceMetric"); require(EVENT_METRICS.containsValue(source), path + ".sourceMetric", "unknown source metric"); int start = (int) integer(e.get("startMs"), 0, MAX_DURATION_MS, path + ".startMs"); int end = (int) integer(e.get("endMs"), 1, MAX_DURATION_MS, path + ".endMs"); - return new BehaviorData.Event((String) type, (String) source, start, end); + return new BehaviorData.Event(type, source, start, end); } // ---- 의미(분석 서버 validation.py) ---- diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicErrors.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicErrors.java index cf83d823..a4c57041 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicErrors.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicErrors.java @@ -13,6 +13,9 @@ * *

    이 서버가 결과 없이 닫은 사유(접수 미확인, 작업 없음, 처리 상한 초과 등)도 화면용 코드로 옮긴다. 그 밖의 내부 코드는 * UNKNOWN_ERROR로만 내보낸다 — DB에 적힌 임의 문자열을 화면 코드로 쓰지 않는다. + * + *

    화면의 {@code error.retryable}과 재분석 허용(canRetry)은 같은 판단({@link #userRetryable})을 쓴다. 공개 오류는 + * retryable=false라고 내보내면서 재분석 버튼은 열리는 어긋남을 만들지 않는다. */ public final class VideoPublicErrors { @@ -32,29 +35,48 @@ public final class VideoPublicErrors { entry("ANALYZER_NOT_CONFIGURED", "영상 행동 분석기가 아직 연결되지 않았습니다.", false), entry("BEHAVIOR_NOT_MEASURABLE", "이 영상에서는 행동 지표를 측정할 수 있는 구간이 충분하지 않습니다.", false)); - // 이 서버가 결과 없이 닫은 사유. 코드 값은 VideoAnalysisService가 error_code에 남기는 것과 같다. - private static final Map SERVICE = Map.ofEntries( - entry("NOT_ACKNOWLEDGED", "분석 서버가 영상 분석 요청을 접수했는지 확인하지 못했습니다.", true), - entry("JOB_NOT_FOUND", "분석 서버에서 영상 분석 작업을 찾지 못했습니다.", true), - entry("PROCESSING_OVERDUE", "영상 분석이 제한 시간 안에 끝나지 않았습니다.", true), - entry("INVALID_RESULT", "영상 분석 결과의 형식을 확인하지 못했습니다.", false), - entry("RESULT_EXPIRED", "영상 분석 결과의 보관 기간이 지났습니다.", false)); + // 이 서버가 결과 없이 닫은 사유의 문구. 코드 값은 VideoAnalysisService가 error_code에 남기는 것과 같다. 다시 해 볼 수 + // 있는지는 닫을 때 남긴 error_retryable을 따른다(비어 있으면 사용자 재분석은 받는다). + private static final Map SERVICE = Map.of( + "NOT_ACKNOWLEDGED", "분석 서버가 영상 분석 요청을 접수했는지 확인하지 못했습니다.", + "JOB_NOT_FOUND", "분석 서버에서 영상 분석 작업을 찾지 못했습니다.", + "PROCESSING_OVERDUE", "영상 분석이 제한 시간 안에 끝나지 않았습니다.", + "INVALID_RESULT", "영상 분석 결과의 형식을 확인하지 못했습니다.", + "RESULT_EXPIRED", "영상 분석 결과의 보관 기간이 지났습니다."); - private static final VideoPublicError UNKNOWN = new VideoPublicError(UNKNOWN_ERROR, "영상 분석을 마치지 못했습니다.", false); + private static final String UNKNOWN_MESSAGE = "영상 분석을 마치지 못했습니다."; private VideoPublicErrors() { } - /** 화면에 내보낼 오류. 모르는 코드나 내부 사유는 UNKNOWN_ERROR다. */ - public static VideoPublicError forDisplay(String storedCode) { - if (storedCode == null) { - return UNKNOWN; + /** + * 화면에 내보낼 오류. 모르는 코드나 내부 사유는 UNKNOWN_ERROR다. retryable은 {@link #userRetryable}과 같다. + * + * @param storedCode 요청 행의 error_code + * @param storedRetryable 요청 행의 error_retryable + */ + public static VideoPublicError forDisplay(String storedCode, Boolean storedRetryable) { + boolean retryable = userRetryable(storedCode, storedRetryable); + VideoPublicError known = storedCode == null ? null : ANALYSIS.get(storedCode); + if (known != null) { + return known; } - VideoPublicError known = ANALYSIS.get(storedCode); - if (known == null) { - known = SERVICE.get(storedCode); + String message = storedCode == null ? null : SERVICE.get(storedCode); + return message == null + ? new VideoPublicError(UNKNOWN_ERROR, UNKNOWN_MESSAGE, retryable) + : new VideoPublicError(storedCode, message, retryable); + } + + /** + * 결과 없이 끝난 요청을 같은 원본으로 다시 분석해 볼 만한지. 분석 서버의 공개 코드면 공개 사전을 따른다. 이 서버가 닫은 + * 사유면 닫을 때 남긴 값을 따른다 — false면 막고, 비어 있거나 true면 받는다. + */ + public static boolean userRetryable(String storedCode, Boolean storedRetryable) { + Boolean known = retryableOf(storedCode); + if (known != null) { + return known; } - return known == null ? UNKNOWN : known; + return !Boolean.FALSE.equals(storedRetryable); } /** 분석 서버가 알릴 수 있는 공개 코드인지. */ diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultDocument.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultDocument.java index 3ab6cfca..80334c06 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultDocument.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultDocument.java @@ -43,6 +43,18 @@ private VideoResultDocument() { * @param expectedVideoId 이 요청으로 보낸 영상(recording_id) */ public static Accepted validate(Map body, String expectedVideoId) { + try { + return check(body, expectedVideoId); + } catch (VideoResultException e) { + throw e; + } catch (RuntimeException e) { + // 규칙이 미처 다루지 못한 모양(타입이 다른 값 등)이라도 5xx로 끝내지 않는다. 5xx면 분석 서버가 같은 본문을 + // 계속 다시 보내고 따로 남지도 않는다. 계약 위반으로 돌려 원문을 따로 남기게 한다. + throw new VideoResultException("$", "unexpected document shape: " + e.getClass().getSimpleName()); + } + } + + private static Accepted check(Map body, String expectedVideoId) { require(!JsonValues.containsNul(body), "$", "NUL character in string"); constant(body.get("schemaVersion"), SCHEMA_VERSION, "$.schemaVersion"); Object rawStatus = body.get("status"); diff --git a/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisService.java b/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisService.java index c20b0a1d..b5d5b419 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisService.java @@ -302,7 +302,7 @@ VideoAnalysisResponse view(InterviewEntity interview, LocalDateTime now) { status = VideoAnalysisResponse.Status.ANALYZING; } else { status = VideoAnalysisResponse.Status.FAILED; - error = VideoPublicErrors.forDisplay(latest.getErrorCode()); + error = VideoPublicErrors.forDisplay(latest.getErrorCode(), latest.getErrorRetryable()); reasonCode = error.code(); } } @@ -430,7 +430,8 @@ String retryBlockedReason(InterviewRecordingEntity video, List result) { switch (callbackHandler.applyRecovered(analysis.getAnalysisId(), result)) { - case INVALID -> fail(analysis, "INVALID_RESULT", null, "작업 조회로 받은 결과가 검증을 통과하지 못했습니다."); + // 계약에 맞지 않는 결과는 같은 원본으로 다시 해도 같은 문서가 나올 수 있다. 사용자 재분석도 막는다. + case INVALID -> fail(analysis, "INVALID_RESULT", false, "작업 조회로 받은 결과가 검증을 통과하지 못했습니다."); case POLICY_HELD -> hold(analysis); default -> { // 저장했거나, 이미 같은 결과가 있거나, 정책상 닫았다. @@ -795,7 +797,7 @@ private void failFromJob(VideoAnalysisEntity analysis, Map error try { code = VideoPublicErrors.validateRemote(error, "$.error"); } catch (VideoResultException e) { - fail(analysis, "INVALID_RESULT", null, "작업 조회의 실패 오류가 공개 오류 계약에 맞지 않습니다. " + e.getMessage()); + fail(analysis, "INVALID_RESULT", false, "작업 조회의 실패 오류가 공개 오류 계약에 맞지 않습니다. " + e.getMessage()); return; } fail(analysis, code, VideoPublicErrors.retryableOf(code), String.valueOf(error.get("message"))); diff --git a/src/main/java/repit/repit_api_server/global/logging/ExternalApiLoggingInterceptor.java b/src/main/java/repit/repit_api_server/global/logging/ExternalApiLoggingInterceptor.java index 49c0a35a..3b0f6d1a 100644 --- a/src/main/java/repit/repit_api_server/global/logging/ExternalApiLoggingInterceptor.java +++ b/src/main/java/repit/repit_api_server/global/logging/ExternalApiLoggingInterceptor.java @@ -27,6 +27,11 @@ public class ExternalApiLoggingInterceptor implements ClientHttpRequestIntercept private static final String REQUEST_ARROW = " ext --> "; private static final String RESPONSE_ARROW = " ext <-- "; private static final String BODY_INDENT = "\n body: "; + private static final String BODY_OMITTED = " body=생략"; + + // 본문을 로그에 남기지 않는 외부 경로. 영상 분석 요청에는 서명된 원본 주소가, 작업 조회 응답에는 행동 관찰 수치가 실린다. + // 들어오는 쪽(RequestLoggingFilter)에서 콜백·조회 본문을 빼도 여기서 남기면 같은 데이터가 로그로 퍼진다. + private static final String BODY_EXCLUDED_PATH = "/analysis/video"; private final String serverName; private final HttpLoggingProperties properties; @@ -65,7 +70,11 @@ private void logRequest(HttpRequest request, byte[] body) { message.append(" auth=").append(authorization); } - appendBody(message, body, request.getHeaders().getContentType()); + if (bodyExcluded(request)) { + message.append(BODY_OMITTED); + } else { + appendBody(message, body, request.getHeaders().getContentType()); + } log.info(message.toString()); } @@ -78,7 +87,12 @@ private void logResponse(HttpRequest request, ClientHttpResponse response, long .append(" ").append(statusText(status)) .append(" (").append(elapsedMillis).append("ms)"); - appendBody(message, readBody(response), response.getHeaders().getContentType()); + if (bodyExcluded(request)) { + // 읽지도 않는다. 호출한 쪽이 그대로 읽는다. + message.append(BODY_OMITTED); + } else { + appendBody(message, readBody(response), response.getHeaders().getContentType()); + } if (status >= 500) { log.error(message.toString()); @@ -118,6 +132,11 @@ private void appendBody(StringBuilder message, byte[] body, MediaType contentTyp } } + private static boolean bodyExcluded(HttpRequest request) { + String path = request.getURI().getPath(); + return path != null && path.contains(BODY_EXCLUDED_PATH); + } + private static String statusText(int status) { HttpStatus resolved = HttpStatus.resolve(status); return resolved == null ? String.valueOf(status) : status + " " + resolved.name(); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorContractTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorContractTest.java index d099d8db..8beaf195 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorContractTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorContractTest.java @@ -70,7 +70,8 @@ class BehaviorContractTest { "durationText", "durationFraction", "nan", "truncatedText", "model", "schema", "reason", "eventSource", "eventShort", "eventOverlap", "eventOrder", "truncatedNotFull", "summaryLong", "unknownWarning", "duplicateWarning", "coverage", "sampleCount", "missingMetric", "timelineGap", "binOverflow", "emptySuccess", - "intervalFraction", "faceExceeded"}) + "intervalFraction", "faceExceeded", "eventTypeNull", "eventSourceNull", "eventTypeNumber", + "reasonNumber"}) void 어긋난_행동_데이터는_받지_않는다(String mutation) { Map raw = fixture("behavior-ready"); mutateBehavior(raw, mutation); @@ -125,6 +126,23 @@ class BehaviorContractTest { assertThatThrownBy(() -> VideoResultDocument.validate(raw, VIDEO)).isInstanceOf(VideoResultException.class); } + /** 규칙이 다루지 못한 모양이라도 NPE·형 변환 오류로 5xx가 되지 않고 계약 위반으로 돌아온다. */ + @Test + void 이벤트_필드가_null이어도_계약_위반으로_돌려준다() { + for (String field : List.of("type", "sourceMetric")) { + Map raw = fixture("callback-ready"); + Map event = obj(((List) data(raw).get("events")).getFirst()); + event.put(field, null); + + assertThatThrownBy(() -> VideoResultDocument.validate(raw, VIDEO)) + .isInstanceOf(VideoResultException.class) + .hasMessageContaining("$.result.analysis.data.events[0]." + field); + } + Map odd = fixture("callback-ready"); + result(odd).put("analysis", List.of("not", "an", "object")); + assertThatThrownBy(() -> VideoResultDocument.validate(odd, VIDEO)).isInstanceOf(VideoResultException.class); + } + @Test void 포장에_계약에_없는_필드가_더_있으면_받는다() { Map raw = fixture("callback-ready"); @@ -211,6 +229,10 @@ private static void mutateBehavior(Map raw, String mutation) { } case "intervalFraction" -> raw.put("sampleIntervalMs", 200.0); case "faceExceeded" -> metric(raw, "faceVisibleRatio").put("numeratorMs", 1); + case "eventTypeNull" -> event(raw, 0).put("type", null); + case "eventSourceNull" -> event(raw, 0).put("sourceMetric", null); + case "eventTypeNumber" -> event(raw, 0).put("type", 3); + case "reasonNumber" -> metric(raw, "faceVisibleRatio").put("unavailableReason", 3); default -> throw new IllegalArgumentException(mutation); } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java index f96ff002..122ce358 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisPersistenceTest.java @@ -365,6 +365,23 @@ void tearDown() { assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.PENDING); } + /** null 이벤트 필드가 NPE로 500이 되면 분석 서버가 같은 본문을 계속 보내고 아무 흔적도 남지 않는다. */ + @Test + void 이벤트_필드가_null인_콜백도_500이_아니라_따로_남긴다() { + VideoAnalysisEntity pending = pendingRow(); + Map body = parse(callback("callback-ready", pending, "job-" + UUID.randomUUID())); + @SuppressWarnings("unchecked") + Map event = (Map) ((List) ((Map) analysisOf(body) + .get("data")).get("events")).getFirst(); + event.put("type", null); + + assertThat(callbackHandler.handle(JSON.writeValueAsString(body))).isEqualTo(VideoAnalysisCallbackHandler.Outcome.INVALID); + + assertThat(jdbc.queryForList("select reason from unmatched_callback where payload like ?", + String.class, "%" + sessionId + "%")).containsExactly("INVALID_BODY"); + assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.PENDING); + } + @Test void 작업_조회로_받은_결과는_콜백과_같게_저장된다() { for (String name : List.of("callback-partial", "callback-unconfigured", "callback-failed")) { @@ -401,6 +418,7 @@ void tearDown() { assertThat(reload(byJob).getStatus()).isEqualTo(VideoAnalysisStatus.FAILED); assertThat(reload(byJob).getErrorCode()).isEqualTo("INVALID_RESULT"); + assertThat(reload(byJob).getErrorRetryable()).isFalse(); assertThat(unmatchedCount()).isEqualTo(1); } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java index dcedd710..c90e4b64 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java @@ -328,7 +328,7 @@ void tearDown() { when(callbackHandler.applyRecovered(ANALYSIS_ID, result)).thenReturn(VideoAnalysisCallbackHandler.Outcome.INVALID); service.check(pending("job-1", 1, LocalDateTime.now().minusMinutes(30))); - verify(analysisRepository).fail(eq(ANALYSIS_ID), eq("INVALID_RESULT"), isNull(), anyString(), any()); + verify(analysisRepository).fail(eq(ANALYSIS_ID), eq("INVALID_RESULT"), eq(false), anyString(), any()); } @Test @@ -697,7 +697,7 @@ void tearDown() { service.check(pending("job-1", 1, LocalDateTime.now().minusMinutes(30))); - verify(analysisRepository).fail(eq(ANALYSIS_ID), eq("INVALID_RESULT"), isNull(), anyString(), any()); + verify(analysisRepository).fail(eq(ANALYSIS_ID), eq("INVALID_RESULT"), eq(false), anyString(), any()); } @Test @@ -725,6 +725,28 @@ void tearDown() { .isNull(); } + /** 공개 오류가 "다시 해도 안 된다"(retryable=false)면 재분석 버튼도 닫혀 있어야 한다. 반대도 같다. */ + @Test + void 화면의_retryable과_canRetry는_같은_판단을_따른다() { + Object[][] cases = { + {"INVALID_RESULT", false}, {"RESULT_EXPIRED", null}, {"NOT_ACKNOWLEDGED", null}, + {"PROCESSING_OVERDUE", null}, {"CONFLICT", null}, {"SESSION_MISSING", false}, + {"INTERVIEW_ABANDONED", false}, {"BEHAVIOR_NOT_MEASURABLE", false}, {"ANALYZER_NOT_CONFIGURED", false}, + {"INTERNAL_ERROR", false}, {null, null}}; + for (Object[] c : cases) { + analyses.clear(); + VideoAnalysisEntity failed = done(1L, "chain-a", VideoAnalysisRequestedBy.AUTO, + VideoAnalysisStatus.FAILED, (Boolean) c[1], null); + failed.setErrorCode((String) c[0]); + analyses.add(failed); + + VideoAnalysisResponse response = service.get(USER_ID, INTERVIEW_ID); + + assertThat(response.status()).as(String.valueOf(c[0])).isEqualTo(VideoAnalysisResponse.Status.FAILED); + assertThat(response.canRetry()).as(String.valueOf(c[0])).isEqualTo(response.error().retryable()); + } + } + @Test void 이미_마친_분석은_canRetry가_거짓이다() { analyses.add(done(1L, "chain-a", VideoAnalysisRequestedBy.AUTO, VideoAnalysisStatus.READY, null, Map.of("v", 1))); diff --git a/src/test/java/repit/repit_api_server/global/logging/ExternalApiLoggingInterceptorTest.java b/src/test/java/repit/repit_api_server/global/logging/ExternalApiLoggingInterceptorTest.java index 81106ae9..b16be69e 100644 --- a/src/test/java/repit/repit_api_server/global/logging/ExternalApiLoggingInterceptorTest.java +++ b/src/test/java/repit/repit_api_server/global/logging/ExternalApiLoggingInterceptorTest.java @@ -62,6 +62,24 @@ class ExternalApiLoggingInterceptorTest { .contains("{\"jobId\":\"job-1\"}")); } + /** 영상 분석 작업 조회 응답에는 행동 관찰 수치가, 요청에는 서명된 원본 주소가 실린다. 본문은 남기지 않는다. */ + @Test + void 영상_분석_요청과_작업_조회의_본문은_남기지_않는다() throws Exception { + MockClientHttpRequest job = new MockClientHttpRequest(HttpMethod.GET, URI.create("http://ai-server/analysis/video/jobs/job-1")); + ClientHttpResponse response = jsonResponse(HttpStatus.OK, + "{\"result\":{\"result\":{\"analysis\":{\"data\":{\"metrics\":{\"headFacingRatio\":{\"value\":0.8}}}}}}}"); + + ClientHttpResponse returned = interceptor.intercept(job, new byte[0], (req, body) -> response); + interceptor.intercept(new MockClientHttpRequest(HttpMethod.POST, URI.create("http://ai-server/analysis/video")), + "{\"video\":{\"videoId\":\"501\"}}".getBytes(StandardCharsets.UTF_8), + (req, body) -> jsonResponse(HttpStatus.ACCEPTED, "{\"jobId\":\"job-1\"}")); + + assertThat(messages()).hasSize(4).allSatisfy(message -> assertThat(message) + .contains("body=생략").doesNotContain("headFacingRatio").doesNotContain("videoId").doesNotContain("job-1\"")); + // 로그가 본문을 읽지 않았으니 호출한 쪽이 그대로 읽는다. + assertThat(new String(returned.getBody().readAllBytes(), StandardCharsets.UTF_8)).contains("headFacingRatio"); + } + @Test void 응답_본문을_로그로_읽어도_호출한_쪽이_같은_본문을_다시_읽는다() throws Exception { // 실제 클라이언트는 BufferingClientHttpRequestFactory로 감싸 두 번 읽을 수 있다.