diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..84c8c711 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,62 @@ +name: Test + +# 배포(deploy.yml)는 테스트를 건너뛰고 이미지를 만든다. PR에서 DB 테스트까지 돌려 병합 전에 막는다. +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + # 빈 DB에 Flyway가 V1부터 적용하고, ddl-auto: validate까지 같이 확인된다. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: repit + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U test -d repit" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + # 외부 서버는 부르지 않는다(테스트가 목으로 바꾼다). 값이 비면 기동이 실패해 자리만 채운다. + env: + DB_URL: jdbc:postgresql://localhost:5432/repit + DB_USERNAME: test + DB_PASSWORD: test + AWS_S3_ACCESS_KEY: test + AWS_S3_SECRET_KEY: test + S3_BUCKET: test-bucket + S3_URL: https://test-bucket.s3.ap-northeast-2.amazonaws.com + AWS_REGION: ap-northeast-2 + AUTH_SERVER: http://127.0.0.1:9 + AI_SERVER: http://127.0.0.1:9 + CHAT_SERVER: http://127.0.0.1:9 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Test + run: ./gradlew test --no-daemon + + - name: Upload test report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequest.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequest.java index 21b23ee1..7266a243 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequest.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequest.java @@ -36,5 +36,7 @@ public static class Error { @JsonAlias("statusCode") private Integer status_code; private String message; + // 원인 코드. REPO_NOT_PUBLIC / INVALID_MATERIAL / INSUFFICIENT_EVIDENCE / INTERNAL. 분기는 message가 아니라 이것으로 한다. + private String code; } } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java index 2bc51f77..9759bd9c 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/ProfileStatusResponse.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.Set; /** 마이페이지와 면접 설정이 보는 최근 종합 데이터 분석 상태. */ @Getter @@ -44,11 +45,17 @@ public static ProfileStatusResponse none() { } /** - * 실패 사유. 분석 서버는 잘못된 자료와 근거 부족을 같은 422로 보내 메시지로 가른다. + * 실패 사유. 분석 서버가 보낸 원인 코드(error.code)가 이 네 값 중 하나면 그대로 쓴다. + * + *

코드가 없거나 모르는 값이면 상태 코드로 고른다 — 코드를 보내기 전에 저장된 실패이거나 분석 서버가 아직 + * 코드를 보내지 않는 경우다. 그때는 잘못된 자료와 근거 부족이 같은 422라 메시지 문구로 가른다. * *

웹은 사유마다 안내가 다르다 — 저장소 공개 설정, 자료 교체, 자료 보강, 다시 시도. */ - public static String reasonOf(Integer statusCode, String message) { + public static String reasonOf(Integer statusCode, String code, String message) { + if (code != null && Set.of(REPO_NOT_PUBLIC, INVALID_MATERIAL, INSUFFICIENT_EVIDENCE, INTERNAL).contains(code)) { + return code; + } if (statusCode == null) { return INTERNAL; } @@ -56,7 +63,6 @@ public static String reasonOf(Integer statusCode, String message) { return REPO_NOT_PUBLIC; } if (statusCode == 422) { - // ponytail: 분석 서버 문구("근거가 부족합니다")에 기댄다. 문구가 바뀌면 잘못된 자료로 읽힌다. 코드를 나누면 그쪽으로 옮긴다. return message != null && message.contains("근거") ? INSUFFICIENT_EVIDENCE : INVALID_MATERIAL; } return INTERNAL; diff --git a/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java b/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java index 00d8f62a..48c5221a 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java @@ -56,6 +56,10 @@ public class AnalysisDataEntity { @Column(columnDefinition = "TEXT") private String errorMessage; + // 실패 콜백의 원인 코드(REPO_NOT_PUBLIC 등). 코드 없이 온 실패면 비어 있다. + @Column(length = 40) + private String errorCode; + // 콜백을 저장한 시각. 같은 jobId로 분석을 다시 요청했을 때 여기 남은 결과가 // 이번 실행의 것인지 지난 실행의 것인지는 이 값으로만 가릴 수 있다. private LocalDateTime completedAt; diff --git a/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java b/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java index 2a7bebc9..a4946f21 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java @@ -109,6 +109,7 @@ Optional findTopByUserIdAndResultTypeOrderByCreatedAtDesc(Lo a.result = null, a.errorStatusCode = null, a.errorMessage = null, + a.errorCode = null, a.completedAt = null, a.createdAt = :requestedAt where a.jobId = :jobId diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/AiMetaDataService.java b/src/main/java/repit/repit_api_server/domain/metadata/service/AiMetaDataService.java index 1e0e1167..7549caa9 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/AiMetaDataService.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/AiMetaDataService.java @@ -102,6 +102,7 @@ public CallbackSuccessResponse saveResult(CallbackSuccessRequest request, Analys data.setResult(request.getResult()); data.setErrorStatusCode(null); data.setErrorMessage(null); + data.setErrorCode(null); data.setCompletedAt(LocalDateTime.now()); return toResponse(analysisDataRepository.save(data)); } @@ -120,6 +121,7 @@ public CallbackSuccessResponse saveResult(CallbackSuccessRequest request, Analys if (request.getError() != null) { data.setErrorStatusCode(request.getError().getStatus_code()); data.setErrorMessage(request.getError().getMessage()); + data.setErrorCode(request.getError().getCode()); } data.setCompletedAt(LocalDateTime.now()); return toResponse(analysisDataRepository.save(data)); @@ -214,12 +216,13 @@ private CallbackSuccessResponse toResponse(AnalysisDataEntity data) { } private CallbackSuccessRequest.Error toError(AnalysisDataEntity data) { - if (data.getErrorStatusCode() == null && data.getErrorMessage() == null) { + if (data.getErrorStatusCode() == null && data.getErrorMessage() == null && data.getErrorCode() == null) { return null; } return CallbackSuccessRequest.Error.builder() .status_code(data.getErrorStatusCode()) .message(data.getErrorMessage()) + .code(data.getErrorCode()) .build(); } @@ -227,7 +230,7 @@ private String describeError(CallbackSuccessRequest.Error error) { if (error == null) { return null; } - return error.getStatus_code() + " " + error.getMessage(); + return error.getStatus_code() + " " + error.getCode() + " " + error.getMessage(); } private String statusName(AnalysisStatus status) { @@ -272,7 +275,7 @@ public ProfileStatusResponse getLatestProfile(Long userId) { .errorStatusCode(data.getErrorStatusCode()) .errorMessage(errorMessage) .failureReason(ProfileStatusResponse.FAILED.equals(status) - ? ProfileStatusResponse.reasonOf(data.getErrorStatusCode(), errorMessage) + ? ProfileStatusResponse.reasonOf(data.getErrorStatusCode(), data.getErrorCode(), errorMessage) : null) .completedAt(data.getCompletedAt()) .projectSummary(ProfileStatusResponse.COMPLETED.equals(status) && data.getResult() instanceof Map result 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 2e05f2ed..de0463bd 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 @@ -12,7 +12,7 @@ public record VideoAnalysisResponse(Status status, Map result, boolean canRetry) { public enum Status { - /** 면접 화면 녹화가 없거나, 보관 기간이 지나 분석할 수 없다. */ + /** 면접 화면 녹화가 없거나, 보관 기간이 지났거나, 영상 분석을 켜기 전에 녹화해 분석하지 않는다. */ NONE, /** 영상은 있고 아직 요청 전이다. */ WAITING, 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 b0421e2f..70bf0dae 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 @@ -34,7 +34,9 @@ import java.time.Duration; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Locale; @@ -119,6 +121,13 @@ public class VideoAnalysisService { @Value("${app.video-analysis.api-token:}") private String videoApiToken; + // 영상 분석을 켠 시각(오프셋을 포함한 ISO-8601). 이 시각까지 올라온 영상은 분석하지 않는다. 켜려면 반드시 있어야 한다. + @Value("${app.video-analysis.analyze-uploaded-after:}") + private String analyzeUploadedAfterSetting; + + // 위 값을 저장된 시각과 같은 시간대로 옮긴 것. 꺼져 있으면 null이다. + private LocalDateTime analyzeUploadedAfter; + @Value("${app.callback-base-url}") private String callbackBaseUrl; @@ -126,15 +135,18 @@ public class VideoAnalysisService { private String bucketName; /** - * 켰는데 서버 간 인증값이 없으면 뜨지 않는다. + * 켰는데 서버 간 인증값이나 분석 기준 시각이 없으면 뜨지 않는다. * *

영상 콜백은 어긋난 본문까지 원문으로 남기므로 인증 없이는 받지 않는다(CallbackPaths.ALWAYS_AUTHENTICATED). * 그 상태로 요청을 보내면 콜백은 모두 401로 튕기고 결과는 조회로만 늦게 들어오는데, 겉으로는 정상처럼 보인다. * 요청 인증값이 없으면 접수가 모두 401로 거절돼 닫힌다. 잘못된 설정을 배포 시점에 드러낸다 — 블루/그린 배포라 * 새 서버가 뜨지 않으면 이전 서버가 그대로 남는다. + * + *

기준 시각이 비어 있을 때 "제한 없음"으로 읽으면 켜는 순간 그동안 쌓인 영상(업로드 89일 이내)이 모두 나간다. + * 켠 뒤 올라온 영상만 분석하기로 했으므로 값을 빠뜨린 배포를 막는다. */ @PostConstruct - void requireAuthTokens() { + void requireSettings() { if (!enabled) { return; } @@ -146,6 +158,18 @@ void requireAuthTokens() { throw new IllegalStateException("영상 분석(VIDEO_ANALYSIS_ENABLED)을 켜려면 VIDEO_ANALYSIS_API_TOKEN이 필요합니다. " + "없으면 분석 서버가 영상 요청을 모두 401로 거절합니다."); } + if (analyzeUploadedAfterSetting == null || analyzeUploadedAfterSetting.isBlank()) { + throw new IllegalStateException("영상 분석(VIDEO_ANALYSIS_ENABLED)을 켜려면 VIDEO_ANALYSIS_UPLOADED_AFTER가 필요합니다. " + + "없으면 켜기 전에 올라온 영상까지 분석합니다."); + } + try { + analyzeUploadedAfter = OffsetDateTime.parse(analyzeUploadedAfterSetting.trim()) + .atZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime(); + } catch (DateTimeParseException e) { + throw new IllegalStateException("VIDEO_ANALYSIS_UPLOADED_AFTER는 오프셋을 포함한 ISO-8601 시각이어야 합니다. " + + "예: 2026-10-20T09:00:00+09:00", e); + } } /** @@ -159,8 +183,12 @@ public void sweep() { 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), startedAt.minus(ANALYZABLE_FOR), Limit.of(BATCH_SIZE))) { + startedAt.minus(UPLOAD_GRACE), uploadedAfter, Limit.of(BATCH_SIZE))) { guarded("영상 분석 요청을 만들지 못했습니다.", video.getInterviewId(), () -> open(video, newChainId(), VideoAnalysisRequestedBy.AUTO)); } @@ -184,7 +212,7 @@ public VideoAnalysisResponse get(Long userId, Long interviewId) { List analyses = analysisRepository.findAllByRecordingIdOrderByAnalysisIdAsc(video.getRecordingId()); if (analyses.isEmpty()) { - VideoAnalysisResponse.Status status = expired(video, now()) + VideoAnalysisResponse.Status status = expired(video, now()) || uploadedBeforeEnabled(video) ? VideoAnalysisResponse.Status.NONE : VideoAnalysisResponse.Status.WAITING; return new VideoAnalysisResponse(status, null, false); @@ -245,6 +273,9 @@ String retryBlockedReason(InterviewRecordingEntity video, List findFirstByProfileJobIdAndModeAndStatusOrderByCycl Optional findByProfileJobIdAndModeAndCycleNo(String profileJobId, InterviewMode mode, Integer cycleNo); + /** + * 이 종합 데이터·모드의 사이클 만들기를 트랜잭션이 끝날 때까지 잠근다. 같은 사이클을 두 요청이 동시에 처음 + * 만들면 잠글 행이 아직 없어 둘 다 "없음"을 보고 저장하고, 늦은 쪽이 유니크 제약에 걸린다. + * + *

두 정수 키 잠금이라 {@code AnalysisDataRepository.lockProfileLaunch}(bigint 키)와 키가 겹치지 않는다. + * hashtext가 겹치면 다른 사이클이 잠깐 기다릴 뿐 결과는 같다. + */ + @Query(value = "select 1 from (select pg_advisory_xact_lock(hashtext(:profileJobId), hashtext(:mode))) locked", + nativeQuery = true) + Integer lockCycleCreation(@Param("profileJobId") String profileJobId, @Param("mode") String mode); + /** 이 종합 데이터의 사이클이 이 코드로 실패한 적이 있는지. */ boolean existsByProfileJobIdAndErrorStatusCode(String profileJobId, Integer errorStatusCode); diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java index fe45a4f1..00fe9ba0 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolService.java @@ -232,11 +232,12 @@ private QuestionCycleEntity prepareCycle(AnalysisDataEntity profile, InterviewMo return null; } LocalDateTime now = LocalDateTime.now(); + // 같은 사이클을 동시에 처음 만드는 요청이 줄을 선다. 늦은 쪽은 앞의 커밋을 기다렸다가 만들어진 행을 본다. + questionCycleRepository.lockCycleCreation(profile.getJobId(), mode.name()); QuestionCycleEntity cycle = questionCycleRepository .findByProfileJobIdAndModeAndCycleNo(profile.getJobId(), mode, cycleNo) .orElse(null); if (cycle == null) { - // ponytail: 같은 사이클을 두 요청이 동시에 처음 만들면 유니크 제약에 한쪽이 500으로 끝난다. 다시 누르면 열린다. return questionCycleRepository.save(QuestionCycleEntity.builder() .userId(profile.getUserId()) .profileJobId(profile.getJobId()) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 88610c48..95fb880d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -120,13 +120,17 @@ app: recording-url-ttl: 6h video-analysis: # 분석 서버가 Chrome·Safari 샘플로 검증을 마치고 영상 분석을 열면 켠다. 꺼져 있으면 면접 화면 녹화를 받아 - # 두기만 하고 보내지 않는다. 켜는 순간 그동안 쌓인 최종 영상(업로드 89일 이내)도 스윕마다 20건씩 나간다. + # 두기만 하고 보내지 않는다. 켠 뒤에는 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일 이내)이 모두 나간다. + analyze-uploaded-after: ${VIDEO_ANALYSIS_UPLOADED_AFTER:} # 업로드를 마친 영상을 요청 자리에 올리고, 보내고, 작업을 조회하고, 자동 재분석을 잇는 간격. # 재전송·조회·재분석 한도는 분석 서버와 맞춘 계약이라 코드(VideoAnalysisService)에 둔다. sweep-interval: 30s diff --git a/src/main/resources/db/migration/V32__add_analysis_data_error_code.sql b/src/main/resources/db/migration/V32__add_analysis_data_error_code.sql new file mode 100644 index 00000000..451a9b60 --- /dev/null +++ b/src/main/resources/db/migration/V32__add_analysis_data_error_code.sql @@ -0,0 +1,4 @@ +-- /profile 실패 콜백의 원인 코드(error.code). 실패 사유(failureReason)를 메시지 문구 대신 이것으로 고른다. +-- 분석 서버가 코드를 보내기 전에 저장된 실패와 코드 없이 온 콜백은 NULL이다. 그때는 상태 코드와 메시지로 고른다. +ALTER TABLE analysis_data + ADD COLUMN error_code VARCHAR(40); diff --git a/src/test/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequestDeserializationTest.java b/src/test/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequestDeserializationTest.java index 2f1bba74..9f6f860d 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequestDeserializationTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/dto/request/CallbackSuccessRequestDeserializationTest.java @@ -36,4 +36,25 @@ class CallbackSuccessRequestDeserializationTest { assertThat(request.getError().getMessage()).isEqualTo("private 저장소입니다."); }); } + + // 분석 서버 docs/api.md 「POST /profile」의 실패 예시. camelCase이고 원인 코드가 따로 온다. + private static final String FAILURE_CALLBACK_WITH_CODE = """ + { + "jobId": "3f0a", + "status": "failed", + "error": { "statusCode": 422, "message": "근거가 부족합니다.", "code": "INSUFFICIENT_EVIDENCE" } + } + """; + + @Test + void camelCase_실패_콜백의_상태_코드와_원인_코드를_읽는다() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + CallbackSuccessRequest request = objectMapper.readValue(FAILURE_CALLBACK_WITH_CODE, CallbackSuccessRequest.class); + + assertThat(request.getError().getStatus_code()).isEqualTo(422); + assertThat(request.getError().getCode()).isEqualTo("INSUFFICIENT_EVIDENCE"); + }); + } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceLatestProfileTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceLatestProfileTest.java index 882193e3..193377d8 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceLatestProfileTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceLatestProfileTest.java @@ -70,6 +70,33 @@ private AnalysisDataEntity failed(int statusCode, String message) { .isEqualTo(ProfileStatusResponse.INTERNAL); } + private AnalysisDataEntity failed(int statusCode, String code, String message) { + AnalysisDataEntity data = failed(statusCode, message); + data.setErrorCode(code); + return data; + } + + /** 분석 서버가 원인 코드를 보내면 문구가 아니라 코드로 고른다. 문구가 바뀌어도, 코드와 엇갈려도 코드가 맞다. */ + @Test + void 원인_코드가_있으면_메시지보다_코드를_따른다() { + assertThat(latest(failed(422, "INSUFFICIENT_EVIDENCE", "카테고리 2종 미만입니다.")).getFailureReason()) + .isEqualTo(ProfileStatusResponse.INSUFFICIENT_EVIDENCE); + assertThat(latest(failed(422, "INVALID_MATERIAL", "근거를 찾을 PDF를 열 수 없습니다.")).getFailureReason()) + .isEqualTo(ProfileStatusResponse.INVALID_MATERIAL); + assertThat(latest(failed(403, "REPO_NOT_PUBLIC", "private")).getFailureReason()) + .isEqualTo(ProfileStatusResponse.REPO_NOT_PUBLIC); + assertThat(latest(failed(500, "INTERNAL", "오류")).getFailureReason()) + .isEqualTo(ProfileStatusResponse.INTERNAL); + } + + @Test + void 모르는_원인_코드는_상태_코드로_고른다() { + assertThat(latest(failed(422, "SOMETHING_NEW", "근거가 부족합니다.")).getFailureReason()) + .isEqualTo(ProfileStatusResponse.INSUFFICIENT_EVIDENCE); + assertThat(latest(failed(403, "SOMETHING_NEW", "?")).getFailureReason()) + .isEqualTo(ProfileStatusResponse.REPO_NOT_PUBLIC); + } + @Test void 분석_중이면_사유를_싣지_않는다() { ProfileStatusResponse response = latest(AnalysisDataEntity.builder() diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceSaveResultTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceSaveResultTest.java index fb00a9c7..05748ea6 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceSaveResultTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/AiMetaDataServiceSaveResultTest.java @@ -120,4 +120,26 @@ void setUp() { assertThat(saved.getError().getStatus_code()).isEqualTo(422); assertThat(saved.getError().getMessage()).isEqualTo("분석 불가"); } + + @Test + void 실패의_원인_코드를_남기고_같은_작업이_성공하면_비운다() { + AnalysisDataEntity row = AnalysisDataEntity.builder().jobId("job-5").status(AnalysisStatus.PENDING).build(); + when(analysisDataRepository.findById("job-5")).thenReturn(Optional.of(row)); + + service.saveResult(CallbackSuccessRequest.builder() + .jobId("job-5") + .status("failed") + .error(CallbackSuccessRequest.Error.builder() + .status_code(422).code("INSUFFICIENT_EVIDENCE").message("근거 부족").build()) + .build(), AnalysisResultType.PROFILE); + assertThat(row.getErrorCode()).isEqualTo("INSUFFICIENT_EVIDENCE"); + + service.saveResult(CallbackSuccessRequest.builder() + .jobId("job-5") + .status("succeeded") + .result(Map.of("profile", Map.of())) + .build(), AnalysisResultType.PROFILE); + assertThat(row.getErrorCode()).isNull(); + assertThat(row.getErrorStatusCode()).isNull(); + } } 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 7cf3e9fe..ed7411df 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 @@ -191,6 +191,23 @@ void tearDown() { .doesNotContain(older.getRecordingId(), justUploaded.getRecordingId(), alreadyRequested.getRecordingId()); } + /** 스윕은 분석을 켠 시각을 uploadedAfter로 넘긴다. 그 시각까지 올라온 영상은 고르지 않는다. */ + @Test + void 분석을_켜기_전에_올라온_영상은_처음_요청할_영상으로_고르지_않는다() { + LocalDateTime enabledAt = LocalDateTime.now().minusHours(1); + InterviewRecordingEntity before = video(interview, enabledAt.minusMinutes(1)); + + InterviewEntity later = otherInterview(); + InterviewRecordingEntity after = video(later, enabledAt.plusMinutes(1)); + + LocalDateTime now = LocalDateTime.now(); + List picked = analysisRepository.findVideosToStart(now.minusMinutes(2), enabledAt, Limit.of(10_000)).stream() + .map(InterviewRecordingEntity::getRecordingId) + .toList(); + + assertThat(picked).contains(after.getRecordingId()).doesNotContain(before.getRecordingId()); + } + @Test void 자동_재분석은_최종_영상의_최신_요청을_묶음당_작업_5개까지만_잇는다() { InterviewRecordingEntity video = video(interview, LocalDateTime.now().minusHours(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 55f3ff80..1176745b 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 @@ -33,6 +33,8 @@ import tools.jackson.databind.json.JsonMapper; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -91,6 +93,8 @@ void setUp() { ReflectionTestUtils.setField(service, "enabled", true); ReflectionTestUtils.setField(service, "internalAuthToken", "secret"); ReflectionTestUtils.setField(service, "videoApiToken", "video-secret"); + // 설정 문자열만 둔다. 기준 시각이 필요한 테스트는 따로 정한다. + ReflectionTestUtils.setField(service, "analyzeUploadedAfterSetting", "2026-10-20T09:00:00+09:00"); ReflectionTestUtils.setField(service, "callbackBaseUrl", "https://api.repit.test"); ReflectionTestUtils.setField(service, "bucketName", "repit-bucket"); @@ -439,20 +443,80 @@ void tearDown() { void 켰는데_서버_간_인증값이_없으면_뜨지_않는다() { ReflectionTestUtils.setField(service, "internalAuthToken", " "); - assertThatThrownBy(service::requireAuthTokens).isInstanceOf(IllegalStateException.class) + assertThatThrownBy(service::requireSettings).isInstanceOf(IllegalStateException.class) .hasMessageContaining("INTERNAL_AUTH_TOKEN"); ReflectionTestUtils.setField(service, "internalAuthToken", "secret"); ReflectionTestUtils.setField(service, "videoApiToken", ""); - assertThatThrownBy(service::requireAuthTokens).isInstanceOf(IllegalStateException.class) + assertThatThrownBy(service::requireSettings).isInstanceOf(IllegalStateException.class) .hasMessageContaining("VIDEO_ANALYSIS_API_TOKEN"); ReflectionTestUtils.setField(service, "enabled", false); - service.requireAuthTokens(); + service.requireSettings(); ReflectionTestUtils.setField(service, "enabled", true); ReflectionTestUtils.setField(service, "videoApiToken", "video-secret"); - service.requireAuthTokens(); + service.requireSettings(); + } + + /** 비어 있을 때 "제한 없음"으로 읽으면 켜는 순간 그동안 쌓인 영상이 모두 나간다. */ + @Test + void 켰는데_분석_기준_시각이_없거나_틀리면_뜨지_않는다() { + ReflectionTestUtils.setField(service, "analyzeUploadedAfterSetting", " "); + assertThatThrownBy(service::requireSettings).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("VIDEO_ANALYSIS_UPLOADED_AFTER"); + + // 오프셋이 없으면 어느 시간대인지 모른다. + ReflectionTestUtils.setField(service, "analyzeUploadedAfterSetting", "2026-10-20T09:00:00"); + assertThatThrownBy(service::requireSettings).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("VIDEO_ANALYSIS_UPLOADED_AFTER"); + + ReflectionTestUtils.setField(service, "analyzeUploadedAfterSetting", "2026-10-20T09:00:00+09:00"); + service.requireSettings(); + assertThat(ReflectionTestUtils.getField(service, "analyzeUploadedAfter")).isEqualTo( + OffsetDateTime.parse("2026-10-20T09:00:00+09:00").atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime()); + + ReflectionTestUtils.setField(service, "enabled", false); + ReflectionTestUtils.setField(service, "analyzeUploadedAfterSetting", ""); + service.requireSettings(); + } + + @Test + void 스윕은_분석을_켠_뒤에_올라온_영상만_고른다() { + LocalDateTime enabledAt = LocalDateTime.now().minusDays(1); + ReflectionTestUtils.setField(service, "analyzeUploadedAfter", enabledAt); + + service.sweep(); + + verify(analysisRepository).findVideosToStart(any(), eq(enabledAt), any()); + } + + @Test + void 분석을_켠_지_89일이_지나면_보관_기간으로_고른다() { + ReflectionTestUtils.setField(service, "analyzeUploadedAfter", LocalDateTime.now().minusDays(100)); + + service.sweep(); + + ArgumentCaptor uploadedAfter = ArgumentCaptor.forClass(LocalDateTime.class); + verify(analysisRepository).findVideosToStart(any(), uploadedAfter.capture(), any()); + assertThat(uploadedAfter.getValue()).isAfter(LocalDateTime.now().minusDays(90)); + } + + /** 대기로 보이면 웹은 오지 않을 분석을 영영 기다린다. */ + @Test + void 분석을_켜기_전에_올라온_영상은_NONE이고_재분석을_받지_않는다() { + // 영상은 하루 전에 올라왔다. + ReflectionTestUtils.setField(service, "analyzeUploadedAfter", LocalDateTime.now().minusHours(1)); + + VideoAnalysisResponse response = service.get(USER_ID, INTERVIEW_ID); + + assertThat(response.status()).isEqualTo(VideoAnalysisResponse.Status.NONE); + assertThat(response.canRetry()).isFalse(); + assertThatThrownBy(() -> service.retry(USER_ID, INTERVIEW_ID)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + + ReflectionTestUtils.setField(service, "analyzeUploadedAfter", LocalDateTime.now().minusDays(2)); + assertThat(service.get(USER_ID, INTERVIEW_ID).status()).isEqualTo(VideoAnalysisResponse.Status.WAITING); } @Test diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolServiceConcurrencyTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolServiceConcurrencyTest.java new file mode 100644 index 00000000..433e5f2d --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionPoolServiceConcurrencyTest.java @@ -0,0 +1,145 @@ +package repit.repit_api_server.domain.userdata.question.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionTemplate; +import repit.repit_api_server.domain.metadata.dto.response.GenerateResponse; +import repit.repit_api_server.domain.metadata.entity.AnalysisDataEntity; +import repit.repit_api_server.domain.metadata.entity.enums.AnalysisResultType; +import repit.repit_api_server.domain.metadata.entity.enums.AnalysisStatus; +import repit.repit_api_server.domain.metadata.repository.AnalysisDataRepository; +import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; +import repit.repit_api_server.domain.userdata.question.repository.QuestionCycleRepository; +import repit.repit_api_server.global.client.AiServerClient; + +import java.time.LocalDateTime; +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.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 같은 사이클을 두 요청이 동시에 처음 만들어도 한 번만 만들고 한 번만 요청한다. 잠글 행이 아직 없는 순간이라 + * 행 잠금으로는 막을 수 없어, 권고 잠금이 정말 둘을 줄 세우는지 DB에 대고 본다. + * + *

트랜잭션이 커밋돼야 다른 스레드가 보므로 테스트 트랜잭션을 쓰지 않고, 만든 행은 끝나고 지운다. + */ +@SpringBootTest +class QuestionPoolServiceConcurrencyTest { + + @Autowired + private QuestionPoolService questionPoolService; + @Autowired + private AnalysisDataRepository analysisDataRepository; + @Autowired + private QuestionCycleRepository questionCycleRepository; + @Autowired + private TransactionTemplate transactionTemplate; + @Autowired + private JdbcTemplate jdbc; + + @MockitoBean + private AiServerClient aiServerClient; + + private final Long userId = ThreadLocalRandom.current().nextLong(1_000_000_000L, 2_000_000_000L); + private final String profileJobId = UUID.randomUUID().toString(); + + @BeforeEach + void setUp() { + analysisDataRepository.saveAndFlush(AnalysisDataEntity.builder() + .jobId(profileJobId) + .userId(userId) + .resultType(AnalysisResultType.PROFILE) + .status(AnalysisStatus.SUCCEEDED) + .result(Map.of("profile", Map.of(), "projectSummary", Map.of())) + .completedAt(LocalDateTime.now()) + .build()); + when(aiServerClient.requestQuestionCycle(any())) + .thenAnswer(invocation -> GenerateResponse.builder().jobId("cycle-" + UUID.randomUUID()).build()); + } + + @AfterEach + void tearDown() { + jdbc.update("delete from question_cycle where user_id = ?", userId); + jdbc.update("delete from analysis_data where user_id = ?", userId); + } + + /** 종합 데이터 콜백이 재전송되는 순간처럼 두 요청이 같은 지점에 서 있다가 함께 출발한다. */ + @Test + void 같은_사이클을_동시에_처음_만들어도_한_번만_만들고_한_번만_요청한다() throws Exception { + CountDownLatch holding = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture holder = CompletableFuture.runAsync(() -> transactionTemplate.executeWithoutResult(status -> { + questionCycleRepository.lockCycleCreation(profileJobId, InterviewMode.SOLO.name()); + holding.countDown(); + awaitQuietly(release); + })); + assertThat(holding.await(10, TimeUnit.SECONDS)).isTrue(); + + CompletableFuture first = CompletableFuture.runAsync(() -> questionPoolService.startCycles(profileJobId)); + CompletableFuture second = CompletableFuture.runAsync(() -> questionPoolService.startCycles(profileJobId)); + awaitLockWaiters(2); + release.countDown(); + + // 유니크 제약에 걸리면 여기서 예외로 드러난다. + CompletableFuture.allOf(holder, first, second).get(30, TimeUnit.SECONDS); + + List modes = jdbc.queryForList( + "select mode from question_cycle where profile_job_id = ? and cycle_no = 1 order by mode", + String.class, profileJobId); + assertThat(modes).containsExactly(InterviewMode.MULTI.name(), InterviewMode.SOLO.name()); + verify(aiServerClient, times(2)).requestQuestionCycle(any()); + } + + @Test + void 모드가_다르면_서로_기다리지_않는다() { + transactionTemplate.executeWithoutResult(status -> { + questionCycleRepository.lockCycleCreation(profileJobId, InterviewMode.SOLO.name()); + + assertThat(otherTransactionCanLock(InterviewMode.SOLO)).isFalse(); + assertThat(otherTransactionCanLock(InterviewMode.MULTI)).isTrue(); + }); + } + + private boolean otherTransactionCanLock(InterviewMode mode) { + return CompletableFuture.supplyAsync(() -> transactionTemplate.execute(status -> jdbc.queryForObject( + "select pg_try_advisory_xact_lock(hashtext(?), hashtext(?))", Boolean.class, profileJobId, mode.name()))) + .join(); + } + + /** 두 요청이 모두 잠금 앞에서 기다릴 때까지. 잠금을 걸지 않으면 여기까지 오지 못한다. */ + private void awaitLockWaiters(int count) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (jdbc.queryForObject( + "select count(*) from pg_locks where locktype = 'advisory' and objsubid = 2 and not granted", + Integer.class) < count) { + if (System.nanoTime() > deadline) { + fail("사이클을 만드는 두 요청이 잠금 앞에서 기다리지 않았습니다."); + } + Thread.sleep(20); + } + } + + private static void awaitQuietly(CountDownLatch latch) { + try { + latch.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +}