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..2514d53d
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorValidator.java
@@ -0,0 +1,333 @@
+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");
+ 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");
+ 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);
+ // 불변 Map은 null 키·값을 조회하면 NPE를 던진다. 문자열인지 먼저 본다.
+ String type = text(e.get("type"), 40, path + ".type");
+ require(EVENT_METRICS.containsKey(type), path + ".type", "unknown event type");
+ 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(type, 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..a4c57041
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoPublicErrors.java
@@ -0,0 +1,111 @@
+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에 적힌 임의 문자열을 화면 코드로 쓰지 않는다.
+ *
+ *
화면의 {@code error.retryable}과 재분석 허용(canRetry)은 같은 판단({@link #userRetryable})을 쓴다. 공개 오류는
+ * retryable=false라고 내보내면서 재분석 버튼은 열리는 어긋남을 만들지 않는다.
+ */
+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에 남기는 것과 같다. 다시 해 볼 수
+ // 있는지는 닫을 때 남긴 error_retryable을 따른다(비어 있으면 사용자 재분석은 받는다).
+ private static final Map SERVICE = Map.of(
+ "NOT_ACKNOWLEDGED", "분석 서버가 영상 분석 요청을 접수했는지 확인하지 못했습니다.",
+ "JOB_NOT_FOUND", "분석 서버에서 영상 분석 작업을 찾지 못했습니다.",
+ "PROCESSING_OVERDUE", "영상 분석이 제한 시간 안에 끝나지 않았습니다.",
+ "INVALID_RESULT", "영상 분석 결과의 형식을 확인하지 못했습니다.",
+ "RESULT_EXPIRED", "영상 분석 결과의 보관 기간이 지났습니다.");
+
+ private static final String UNKNOWN_MESSAGE = "영상 분석을 마치지 못했습니다.";
+
+ private VideoPublicErrors() {
+ }
+
+ /**
+ * 화면에 내보낼 오류. 모르는 코드나 내부 사유는 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;
+ }
+ 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 !Boolean.FALSE.equals(storedRetryable);
+ }
+
+ /** 분석 서버가 알릴 수 있는 공개 코드인지. */
+ 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..80334c06
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/analysis/behavior/VideoResultDocument.java
@@ -0,0 +1,119 @@
+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) {
+ 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");
+ 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/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..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
@@ -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(), latest.getErrorRetryable());
+ 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) {
@@ -289,7 +430,8 @@ 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 +462,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 +550,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 +621,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 +660,37 @@ 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", false, "작업 조회로 받은 결과가 검증을 통과하지 못했습니다.");
+ 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 +791,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", false, "작업 조회의 실패 오류가 공개 오류 계약에 맞지 않습니다. " + 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 +820,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/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/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/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/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/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/behavior/BehaviorContractTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorContractTest.java
new file mode 100644
index 00000000..8beaf195
--- /dev/null
+++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/behavior/BehaviorContractTest.java
@@ -0,0 +1,409 @@
+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", "eventTypeNull", "eventSourceNull", "eventTypeNumber",
+ "reasonNumber"})
+ 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);
+ }
+
+ /** 규칙이 다루지 못한 모양이라도 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");
+ 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);
+ 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);
+ }
+ }
+
+ 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/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..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
@@ -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,447 @@ 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 문서_생성_실패는_결과_없이_FAILED로_닫는다() {
+ 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();
- callbackHandler.handle(example(CALLBACK_FAILED, pending, "job-" + UUID.randomUUID()));
+ 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로_닫고_원문을_남긴다() {
+ VideoAnalysisEntity pending = pendingRow();
+
+ 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);
- callbackHandler.handle(example(CALLBACK_READY, pending, "job-" + UUID.randomUUID()));
-
- 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")).isEqualTo(Map.of("gaze", 0.8));
+ 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 다른_녹화의_결과는_ID_MISMATCH로_따로_남기고_행은_그대로다() {
+ 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_UNCONFIGURED, pending, "job-" + UUID.randomUUID())
- .replace("\"videoId\": \"" + pending.getRecordingId() + "\"", "\"videoId\": \"" + (pending.getRecordingId() + 1) + "\""));
+ 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("ID_MISMATCH");
- VideoAnalysisEntity untouched = reload(pending);
- assertThat(untouched.getStatus()).isEqualTo(VideoAnalysisStatus.PENDING);
- assertThat(untouched.getResult()).isNull();
+ assertThat(unmatchedCount()).isEqualTo(1);
+ assertThat(reload(pending).getStatus()).isEqualTo(VideoAnalysisStatus.PENDING);
}
+ /** null 이벤트 필드가 NPE로 500이 되면 분석 서버가 같은 본문을 계속 보내고 아무 흔적도 남지 않는다. */
@Test
- void 재시도할_만한_파일_오류는_자동_재분석으로_잇는다() {
+ 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);
- 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(JSON.writeValueAsString(body))).isEqualTo(VideoAnalysisCallbackHandler.Outcome.INVALID);
- VideoAnalysisEntity stored = reload(pending);
- assertThat(stored.getErrorCode()).isEqualTo("SOURCE_DOWNLOAD_FAILED");
- assertThat(stored.getErrorRetryable()).isTrue();
- assertThat(candidates()).extracting(VideoAnalysisEntity::getAnalysisId).containsExactly(pending.getAnalysisId());
+ 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 작업_조회로_받은_결과는_콜백과_같게_저장된다() {
- 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()));
+ 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 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);
+ 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(reload(byJob).getErrorRetryable()).isFalse();
+ assertThat(unmatchedCount()).isEqualTo(1);
+ }
+
+ // ---- 멱등성·늦은 결과 ----
+
+ @Test
+ void 키_순서만_다른_같은_종료_문서는_중복이라_아무것도_바꾸지_않는다() {
+ VideoAnalysisEntity pending = pendingRow();
+ String jobId = "job-" + UUID.randomUUID();
+ callbackHandler.handle(callback("callback-partial", pending, jobId));
+ LocalDateTime updatedAt = reload(pending).getUpdatedAt();
+
+ Object reordered = reversed(parse(callback("callback-partial", pending, jobId)));
+
+ assertThat(callbackHandler.handle(JSON.writeValueAsString(reordered)))
+ .isEqualTo(VideoAnalysisCallbackHandler.Outcome.DUPLICATE);
+ assertThat(reload(pending).getUpdatedAt()).isEqualTo(updatedAt);
+ assertThat(unmatchedCount()).isZero();
+ }
+
+ /** 먼저 저장한 결과가 이긴다. ready가 늦게 온 failed로 바뀌지 않고, 늦게 온 문서는 원인을 볼 수 있게 따로 남는다. */
+ @Test
+ void 상충하는_종료_문서는_처음_것을_두고_따로_남긴다() {
+ VideoAnalysisEntity pending = pendingRow();
+ String jobId = "job-" + UUID.randomUUID();
+ callbackHandler.handle(callback("callback-ready", pending, jobId));
+
+ assertThat(callbackHandler.handle(callback("callback-failed", pending, jobId)))
+ .isEqualTo(VideoAnalysisCallbackHandler.Outcome.CONFLICT);
+
+ 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 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 +693,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 +777,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 +803,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..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
@@ -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,14 +321,14 @@ 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());
+ verify(analysisRepository).fail(eq(ANALYSIS_ID), eq("INVALID_RESULT"), eq(false), anyString(), any());
}
@Test
@@ -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 꺼져_있어도_맡긴_요청은_분석_중이고_저장된_결과는_보이되_재분석은_막힌다() {
+ 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.FAILED);
+ assertThat(response.canRetry()).isFalse();
}
@Test
- void 분석이_꺼져_있고_요청이_없는_영상은_WAITING이며_재분석할_수_없다() {
+ void 분석이_꺼져_있고_요청이_없는_영상은_NONE이며_재분석할_수_없다() {
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.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,174 @@ 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"), eq(false), 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();
+ }
+
+ /** 공개 오류가 "다시 해도 안 된다"(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)));
+
+ 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/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);
- }
}
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로 감싸 두 번 읽을 수 있다.
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