Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.Set;

/** 마이페이지와 면접 설정이 보는 최근 종합 데이터 분석 상태. */
@Getter
Expand Down Expand Up @@ -44,19 +45,24 @@ public static ProfileStatusResponse none() {
}

/**
* 실패 사유. 분석 서버는 잘못된 자료와 근거 부족을 같은 422로 보내 메시지로 가른다.
* 실패 사유. 분석 서버가 보낸 원인 코드(error.code)가 이 네 값 중 하나면 그대로 쓴다.
*
* <p>코드가 없거나 모르는 값이면 상태 코드로 고른다 — 코드를 보내기 전에 저장된 실패이거나 분석 서버가 아직
* 코드를 보내지 않는 경우다. 그때는 잘못된 자료와 근거 부족이 같은 422라 메시지 문구로 가른다.
*
* <p>웹은 사유마다 안내가 다르다 — 저장소 공개 설정, 자료 교체, 자료 보강, 다시 시도.
*/
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;
}
if (statusCode == 403) {
return REPO_NOT_PUBLIC;
}
if (statusCode == 422) {
// ponytail: 분석 서버 문구("근거가 부족합니다")에 기댄다. 문구가 바뀌면 잘못된 자료로 읽힌다. 코드를 나누면 그쪽으로 옮긴다.
return message != null && message.contains("근거") ? INSUFFICIENT_EVIDENCE : INVALID_MATERIAL;
}
return INTERNAL;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ Optional<AnalysisDataEntity> findTopByUserIdAndResultTypeOrderByCreatedAtDesc(Lo
a.result = null,
a.errorStatusCode = null,
a.errorMessage = null,
a.errorCode = null,
a.completedAt = null,
a.createdAt = :requestedAt
where a.jobId = :jobId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand All @@ -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));
Expand Down Expand Up @@ -214,20 +216,21 @@ 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();
}

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) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
public record VideoAnalysisResponse(Status status, Map<String, Object> result, boolean canRetry) {

public enum Status {
/** 면접 화면 녹화가 없거나, 보관 기간이 지나 분석할 수 없다. */
/** 면접 화면 녹화가 없거나, 보관 기간이 지났거나, 영상 분석을 켜기 전에 녹화해 분석하지 않는다. */
NONE,
/** 영상은 있고 아직 요청 전이다. */
WAITING,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -119,22 +121,32 @@ 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;

@Value("${spring.cloud.aws.s3.bucket}")
private String bucketName;

/**
* 켰는데 서버 간 인증값이 없으면 뜨지 않는다.
* 켰는데 서버 간 인증값이나 분석 기준 시각이 없으면 뜨지 않는다.
*
* <p>영상 콜백은 어긋난 본문까지 원문으로 남기므로 인증 없이는 받지 않는다(CallbackPaths.ALWAYS_AUTHENTICATED).
* 그 상태로 요청을 보내면 콜백은 모두 401로 튕기고 결과는 조회로만 늦게 들어오는데, 겉으로는 정상처럼 보인다.
* 요청 인증값이 없으면 접수가 모두 401로 거절돼 닫힌다. 잘못된 설정을 배포 시점에 드러낸다 — 블루/그린 배포라
* 새 서버가 뜨지 않으면 이전 서버가 그대로 남는다.
*
* <p>기준 시각이 비어 있을 때 "제한 없음"으로 읽으면 켜는 순간 그동안 쌓인 영상(업로드 89일 이내)이 모두 나간다.
* 켠 뒤 올라온 영상만 분석하기로 했으므로 값을 빠뜨린 배포를 막는다.
*/
@PostConstruct
void requireAuthTokens() {
void requireSettings() {
if (!enabled) {
return;
}
Expand All @@ -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);
}
}

/**
Expand All @@ -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));
}
Expand All @@ -184,7 +212,7 @@ public VideoAnalysisResponse get(Long userId, Long interviewId) {
List<VideoAnalysisEntity> 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);
Expand Down Expand Up @@ -245,6 +273,9 @@ String retryBlockedReason(InterviewRecordingEntity video, List<VideoAnalysisEnti
if (expired(video, now)) {
return "녹화한 지 90일이 지나 다시 분석할 수 없습니다.";
}
if (uploadedBeforeEnabled(video)) {
return "영상 분석을 시작하기 전에 녹화한 영상이라 분석할 수 없습니다.";
}
if (analyses.isEmpty()) {
return "아직 분석을 시작하지 않았습니다.";
}
Expand Down Expand Up @@ -584,6 +615,11 @@ private static boolean expired(InterviewRecordingEntity video, LocalDateTime now
return !now.isBefore(video.getCreatedAt().plus(ANALYZABLE_FOR));
}

/** 영상 분석을 켜기 전에 올라와 분석하지 않는 영상인지. 스윕이 고르는 기준(올라온 시각 &gt; 기준 시각)과 같다. */
private boolean uploadedBeforeEnabled(InterviewRecordingEntity video) {
return analyzeUploadedAfter != null && !video.getCreatedAt().isAfter(analyzeUploadedAfter);
}

private static Duration resendDelay(int sent) {
return RESEND_BASE_DELAY.multipliedBy(1L << Math.min(sent - 1, 10));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ Optional<QuestionCycleEntity> findFirstByProfileJobIdAndModeAndStatusOrderByCycl

Optional<QuestionCycleEntity> findByProfileJobIdAndModeAndCycleNo(String profileJobId, InterviewMode mode, Integer cycleNo);

/**
* 이 종합 데이터·모드의 사이클 만들기를 트랜잭션이 끝날 때까지 잠근다. 같은 사이클을 두 요청이 동시에 처음
* 만들면 잠글 행이 아직 없어 둘 다 "없음"을 보고 저장하고, 늦은 쪽이 유니크 제약에 걸린다.
*
* <p>두 정수 키 잠금이라 {@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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
6 changes: 5 additions & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- /profile 실패 콜백의 원인 코드(error.code). 실패 사유(failureReason)를 메시지 문구 대신 이것으로 고른다.
-- 분석 서버가 코드를 보내기 전에 저장된 실패와 코드 없이 온 콜백은 NULL이다. 그때는 상태 코드와 메시지로 고른다.
ALTER TABLE analysis_data
ADD COLUMN error_code VARCHAR(40);
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
}
}
Loading
Loading