diff --git a/src/main/java/repit/repit_api_server/domain/company/entity/CompanyEntity.java b/src/main/java/repit/repit_api_server/domain/company/entity/CompanyEntity.java
index dd704fbf..2b2d469d 100644
--- a/src/main/java/repit/repit_api_server/domain/company/entity/CompanyEntity.java
+++ b/src/main/java/repit/repit_api_server/domain/company/entity/CompanyEntity.java
@@ -17,7 +17,8 @@
/**
* 면접에서 고를 수 있는 기업. 기동할 때 기업 데이터 패키지에서 가져온다 — {@code CompanyDataImporter} 참고.
*
- *
행은 지우지 않는다. 면접이 company_id로 가리키고 있어, 패키지에서 빠진 기업은 HIDDEN으로만 바꾼다.
+ *
행은 지우지 않는다. 면접이 company_id로 가리키고 있어, 내릴 기업은 설정(app.company.hidden-codes)으로 HIDDEN만 만든다.
+ * 목록·신규 선택에 내보내는 조건은 {@code CompanyVisibility}가 정한다.
*/
@Entity
@Table(name = "company")
@@ -80,6 +81,10 @@ public class CompanyEntity {
@Column(nullable = false)
private LocalDate dataAsOf;
+ // 출처가 모두 SAMPLE인 개발용 패키지. 허용한 환경(app.company.allow-sample)에서만 목록·면접 생성에 내보낸다.
+ @Column(nullable = false)
+ private boolean sample;
+
@Column(length = 50)
private String reviewedBy;
diff --git a/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanyStatus.java b/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanyStatus.java
index 3c2499f2..283d039f 100644
--- a/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanyStatus.java
+++ b/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanyStatus.java
@@ -1,6 +1,10 @@
package repit.repit_api_server.domain.company.entity.enums;
-/** 기업 행은 지우지 않는다. 패키지에서 빠지면 HIDDEN이 되고 목록·면접 생성에서 보이지 않는다. */
+/**
+ * 기업 행은 지우지 않는다. HIDDEN은 목록·면접 생성에서 보이지 않고, 이미 만든 면접의 스냅샷은 그대로 둔다.
+ *
+ *
HIDDEN은 설정(app.company.hidden-codes)으로만 만든다. 패키지 파일이 디렉터리에서 빠졌다고 바꾸지 않는다.
+ */
public enum CompanyStatus {
PUBLISHED,
HIDDEN
diff --git a/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataImporter.java b/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataImporter.java
index f308a3e4..70e5af41 100644
--- a/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataImporter.java
+++ b/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataImporter.java
@@ -14,10 +14,12 @@
import repit.repit_api_server.domain.company.entity.CompanyEntity;
import repit.repit_api_server.domain.company.entity.CompanyFactEntity;
import repit.repit_api_server.domain.company.entity.CompanySourceEntity;
+import repit.repit_api_server.domain.company.entity.enums.CompanySourceType;
import repit.repit_api_server.domain.company.entity.enums.CompanyStatus;
import repit.repit_api_server.domain.company.repository.CompanyFactRepository;
import repit.repit_api_server.domain.company.repository.CompanyRepository;
import repit.repit_api_server.domain.company.repository.CompanySourceRepository;
+import repit.repit_api_server.domain.company.service.CompanyVisibility;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
@@ -42,7 +44,17 @@
* 기동할 때 기업 데이터 패키지(JSON)를 읽어 DB에 반영한다.
*
*
기동을 실패시키지 않는다. 데이터 문제로 API 전체가 뜨지 않으면 안 된다. 문제가 있는 파일만 건너뛰고
- * 오류 로그를 남긴다. 위치를 비워 두면 아무것도 하지 않고, 공개 기업은 기존 DB 그대로다.
+ * 오류 로그를 남긴다. 위치를 비워 두면 가져오지 않고(설정한 숨김만 한다), 공개 기업은 기존 DB 그대로다.
+ *
+ *
숨김은 설정(app.company.hidden-codes)으로만 한다. 파일이 디렉터리에서 빠졌다고 숨기거나 지우지 않는다 — 위치를 잘못
+ * 적거나 일부 파일만 배포돼도 공개 기업이 한꺼번에 사라지지 않는다. 빠진 기업은 경고만 남긴다.
+ *
+ *
SAMPLE 패키지(출처가 모두 SAMPLE)는 app.company.allow-sample이 켜진 환경(개발·스테이징)에서만 가져온다.
+ *
+ *
기존 기업은 행을 FOR UPDATE로 잡고 고친다. 면접 생성의 스냅샷은 같은 행을 FOR SHARE로 읽으므로 반쯤 바뀐 기업을
+ * 스냅샷으로 남기지 않는다({@code CompanySnapshotFactory}).
+ *
+ *
로그에는 파일명·code·결과·사유만 남긴다. 파일 원문, 출처 주소, 신호 문장은 남기지 않는다.
*
*
API는 1대 전제다. 여러 대가 동시에 가져와도 code UNIQUE와 기업별 트랜잭션 때문에 데이터가 깨지지는 않는다.
*
@@ -61,12 +73,17 @@ public class CompanyDataImporter implements SmartInitializingSingleton {
private final CompanyFactRepository companyFactRepository;
private final TransactionTemplate transactionTemplate;
private final ObjectMapper objectMapper;
+ private final CompanyVisibility companyVisibility;
private final ResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
// Spring 리소스 패턴. 예: classpath:company-data/sample/*.json
@Value("${app.company.data-location:}")
private String dataLocation;
+ // 내릴 기업의 code. 쉼표로 나눈다. 목록에서 빼면 다음 가져오기 때 다시 공개한다.
+ @Value("${app.company.hidden-codes:}")
+ private String hiddenCodes;
+
@Override
public void afterSingletonsInstantiated() {
try {
@@ -77,19 +94,27 @@ public void afterSingletonsInstantiated() {
}
public Result importFrom(String location) throws IOException {
+ return importFrom(location, parseCodes(hiddenCodes));
+ }
+
+ public Result importFrom(String location, Set hiddenCodes) throws IOException {
+ // 숨김은 파일과 상관없이 설정만 보고 한다. 위치를 비워 둔 환경에서도 내릴 기업은 내린다.
+ // 반영보다 먼저 한다 — 숨긴 기업이 들고 있던 식별자를 이번 묶음이 받을 수 있게 놓게 한 뒤 반영해야, code를 바꿔 다시 낸
+ // 기업이 DART 고유번호 UNIQUE에 걸려 끝내 들어가지 못하는 일이 없다.
+ int hidden = transactionTemplate.execute(status -> hide(hiddenCodes));
if (location == null || location.isBlank()) {
log.info("기업 데이터 위치가 설정되지 않아 가져오지 않습니다");
- return Result.NONE;
+ return new Result(0, 0, 0, hidden, 0);
}
Resource[] resources = resourceResolver.getResources(location);
if (resources.length == 0) {
- // 위치를 잘못 적었을 수 있다. 여기서 숨김까지 하면 공개 기업이 모두 사라진다.
+ // 위치를 잘못 적었을 수 있다. 공개 기업은 그대로 둔다.
log.warn("기업 데이터 파일이 없어 가져오지 않습니다. location={}", location);
- return Result.NONE;
+ return new Result(0, 0, 0, hidden, 0);
}
Arrays.sort(resources, Comparator.comparing(Resource::getFilename));
- // 이번에 읽은 기업. 검증에 실패한 파일도 넣어, 그 기업은 숨기지 않고 기존 상태를 둔다.
+ // 이번에 읽은 기업. 검증에 실패한 파일도 넣는다 — 모회사 파일이 실패했을 때 자회사의 기존 연결을 두는 데 쓴다.
Set readCodes = new HashSet<>();
List loaded = new ArrayList<>();
int skipped = 0;
@@ -103,7 +128,8 @@ public Result importFrom(String location) throws IOException {
bytes = resource.getContentAsByteArray();
pkg = objectMapper.readValue(bytes, CompanyDataPackage.class);
} catch (IOException | JacksonException e) {
- log.error("기업 데이터 파일을 읽지 못해 건너뜁니다. file={}, 오류={}", fileName, e.getMessage());
+ // 파서 메시지는 파일 내용 일부를 담는다. 예외 종류만 남긴다.
+ log.error("기업 데이터 파일을 읽지 못해 건너뜁니다. file={}, 오류={}", fileName, e.getClass().getSimpleName());
skipped++;
continue;
}
@@ -118,19 +144,27 @@ public Result importFrom(String location) throws IOException {
List errors = CompanyDataValidator.validate(fileName, pkg);
if (!errors.isEmpty()) {
- log.error("기업 데이터 파일이 규칙에 맞지 않아 건너뜁니다. file={}, 오류={}", fileName, errors);
+ log.error("기업 데이터 파일이 규칙에 맞지 않아 건너뜁니다. file={}, code={}, 오류={}", fileName, pkg.code(), errors);
+ skipped++;
+ continue;
+ }
+ if (isSample(pkg) && !companyVisibility.allowsSample()) {
+ // 운영에 샘플이 공개되지 않게 한다. 검수 예외를 운영 검수 우회로 쓰지 않는다.
+ log.error("SAMPLE 기업 데이터는 이 환경에서 가져오지 않습니다. file={}, code={}", fileName, pkg.code());
skipped++;
continue;
}
+ if (hiddenCodes.contains(pkg.code())) {
+ log.info("숨기도록 설정한 기업이라 반영하지 않습니다. file={}, code={}", fileName, pkg.code());
+ continue;
+ }
loaded.add(new Loaded(fileName, pkg, sha256(bytes)));
}
List unique = withoutDuplicates(loaded);
skipped += loaded.size() - unique.size();
- // 숨김은 읽은 파일 목록만으로 정해지므로 반영보다 먼저 한다. 숨긴 기업이 들고 있던 식별자를 이번 묶음이 받을 수 있게
- // 놓게 한 뒤 반영한다 — 그러지 않으면 code를 바꿔 다시 낸 기업이 DART 고유번호 UNIQUE에 걸려 끝내 들어가지 못한다.
- int hidden = transactionTemplate.execute(status -> hideMissing(readCodes));
+ warnMissing(readCodes, hiddenCodes);
transactionTemplate.executeWithoutResult(status -> releaseClaimedIdentifiers(unique));
Map tally = new EnumMap<>(Outcome.class);
@@ -141,8 +175,7 @@ public Result importFrom(String location) throws IOException {
if (outcome == null) {
retry.add(each);
} else {
- tally.merge(outcome, 1, Integer::sum);
- applied.add(each.pkg());
+ record(tally, applied, each, outcome);
}
}
// 다른 기업이 먼저 놓아야 받을 수 있는 값(이 묶음 안에서 DART 고유번호를 옮긴 경우)은 처리 순서에 따라 처음에 막힌다.
@@ -152,8 +185,7 @@ public Result importFrom(String location) throws IOException {
if (outcome == null) {
skipped++;
} else {
- tally.merge(outcome, 1, Integer::sum);
- applied.add(each.pkg());
+ record(tally, applied, each, outcome);
}
}
@@ -166,16 +198,25 @@ public Result importFrom(String location) throws IOException {
return result;
}
+ private static void record(Map tally, List applied, Loaded each,
+ Outcome outcome) {
+ tally.merge(outcome, 1, Integer::sum);
+ applied.add(each.pkg());
+ log.info("기업 데이터 반영: file={}, code={}, 결과={}", each.fileName(), each.pkg().code(), outcome);
+ }
+
/** 기업 하나 = 트랜잭션 하나. 실패하면 그 기업만 롤백하고 null을 돌려준다. */
private Outcome applyInOwnTransaction(Loaded each, boolean firstTry) {
try {
return transactionTemplate.execute(status -> apply(each));
} catch (RuntimeException e) {
+ // DB 예외 메시지는 넣으려던 값을 담는다. 예외 종류만 남긴다.
if (firstTry) {
- log.warn("기업 데이터를 반영하지 못해 나머지를 반영한 뒤 한 번 더 해봅니다. file={}, 오류={}",
- each.fileName(), e.getMessage());
+ log.warn("기업 데이터를 반영하지 못해 나머지를 반영한 뒤 한 번 더 해봅니다. file={}, code={}, 오류={}",
+ each.fileName(), each.pkg().code(), e.getClass().getSimpleName());
} else {
- log.error("기업 데이터를 반영하지 못해 건너뜁니다. file={}", each.fileName(), e);
+ log.error("기업 데이터를 반영하지 못해 건너뜁니다. file={}, code={}, 오류={}",
+ each.fileName(), each.pkg().code(), e.getClass().getSimpleName());
}
return null;
}
@@ -213,7 +254,9 @@ private List withoutDuplicates(List loaded) {
if (duplicated.isEmpty()) {
unique.add(each);
} else {
- log.error("다른 기업 데이터 파일과 겹쳐 건너뜁니다. file={}, 겹친 값={}", each.fileName(), duplicated);
+ // 법인 식별번호 값은 남기지 않고 어느 키가 겹쳤는지만 남긴다.
+ log.error("다른 기업 데이터 파일과 겹쳐 건너뜁니다. file={}, code={}, 겹친 키={}", each.fileName(),
+ each.pkg().code(), duplicated.stream().map(key -> key.substring(0, key.indexOf('='))).toList());
}
}
return unique;
@@ -234,11 +277,12 @@ private static List uniqueKeys(CompanyDataPackage pkg) {
/**
* 기업 하나를 반영한다. 내용이 그대로면 손대지 않고, 바뀌었으면 기업 컬럼을 고친 뒤 정보·출처를 지우고 다시 넣는다.
*
- * 다시 넣으면 fact_id가 바뀐다. 이미 만든 면접은 스냅샷 안의 factId를 쓰므로 영향이 없다.
+ *
다시 넣으면 fact_id가 바뀐다(시퀀스라 지난 id를 다시 쓰지 않는다). 이미 만든 면접은 스냅샷 안의 factId를 쓰므로
+ * 영향이 없다. 기존 기업은 행을 FOR UPDATE로 잡은 뒤 고친다 — 스냅샷이 반쯤 바뀐 기업을 읽지 못하게 한다.
*/
private Outcome apply(Loaded loaded) {
CompanyDataPackage pkg = loaded.pkg();
- CompanyEntity company = companyRepository.findByCode(pkg.code()).orElse(null);
+ CompanyEntity company = companyRepository.findForUpdateByCode(pkg.code()).orElse(null);
if (company != null && loaded.hash().equals(company.getContentHash())
&& company.getStatus() == CompanyStatus.PUBLISHED) {
return Outcome.UNCHANGED;
@@ -263,6 +307,7 @@ private Outcome apply(Loaded loaded) {
company.setDartCorpCode(pkg.dartCorpCode());
company.setBusinessRegNo(pkg.businessRegNo());
company.setStatus(CompanyStatus.PUBLISHED);
+ company.setSample(isSample(pkg));
company.setInterviewerBrief(pkg.interviewerBrief());
company.setDataAsOf(pkg.dataAsOf());
company.setReviewedBy(pkg.review() == null ? null : pkg.review().reviewedBy());
@@ -305,20 +350,33 @@ private Outcome apply(Loaded loaded) {
}
/**
- * 묶음 규칙 10. 모회사는 이번 묶음에 있어야 잇는다. 묶음에 아예 없으면 연결만 빼고 경고한다.
+ * 묶음 규칙 8. 모회사는 이번 묶음에 있어야 잇는다. 묶음에 아예 없으면 연결만 빼고 경고한다.
*
*
모회사 파일이 묶음에 있는데 검증·반영에 실패했으면 그 기업 행은 기존 상태로 남는다. 그때는 연결도 지금 그대로 둔다 —
* 모회사 파일 하나가 잠깐 틀렸다고 자회사의 연결까지 끊지 않는다.
+ *
+ *
자기 자신을 가리키거나 따라가다 자기에게 돌아오는 연결은 저장하지 않는다. 목록에 자기 이름이 모회사로 나가거나,
+ * 모회사를 따라가는 코드가 끝없이 돈다. 파일 자체는 반영한다.
*/
private void linkParents(List applied, Set readCodes) {
- Set codes = applied.stream().map(CompanyDataPackage::code).collect(Collectors.toSet());
+ Map intended = new HashMap<>();
+ applied.forEach(pkg -> intended.put(pkg.code(), pkg.parentCode()));
for (CompanyDataPackage pkg : applied) {
CompanyEntity company = companyRepository.findByCode(pkg.code()).orElseThrow();
String parentCode = pkg.parentCode();
if (parentCode == null) {
company.setParent(null);
- } else if (codes.contains(parentCode)) {
- company.setParent(companyRepository.findByCode(parentCode).orElseThrow());
+ } else if (parentCode.equals(pkg.code())) {
+ log.warn("모회사가 자기 자신이라 연결하지 않습니다. code={}", pkg.code());
+ company.setParent(null);
+ } else if (intended.containsKey(parentCode)) {
+ if (returnsTo(pkg.code(), intended)) {
+ log.warn("모회사 연결이 돌고 돌아 자기에게 와서 연결하지 않습니다. code={}, parentCode={}",
+ pkg.code(), parentCode);
+ company.setParent(null);
+ } else {
+ company.setParent(companyRepository.findByCode(parentCode).orElseThrow());
+ }
} else if (readCodes.contains(parentCode)) {
log.warn("모회사 기업 데이터를 반영하지 못해 모회사 연결은 그대로 둡니다. code={}, parentCode={}",
pkg.code(), parentCode);
@@ -330,18 +388,75 @@ private void linkParents(List applied, Set readCodes
}
}
- /** 이번에 읽은 파일에 아예 없는 공개 기업을 숨긴다. 행은 면접이 가리키고 있어 지우지 않는다. */
- private int hideMissing(Set readCodes) {
+ /**
+ * code에서 모회사를 따라가다 code로 돌아오는지. 이번 묶음에 있는 기업은 파일의 parentCode를, 없는 기업은 DB의 연결을 따른다.
+ * 다른 데서 도는 고리에 들어가면 code로는 돌아오지 않으므로 거기서 멈춘다.
+ */
+ private boolean returnsTo(String code, Map intended) {
+ Set visited = new HashSet<>();
+ String current = intended.get(code);
+ while (current != null) {
+ if (current.equals(code)) {
+ return true;
+ }
+ if (!visited.add(current)) {
+ return false;
+ }
+ current = intended.containsKey(current)
+ ? intended.get(current)
+ : companyRepository.findByCode(current)
+ .map(CompanyEntity::getParent)
+ .map(CompanyEntity::getCode)
+ .orElse(null);
+ }
+ return false;
+ }
+
+ /** 설정으로 내린 기업을 숨긴다. 행은 면접이 가리키고 있어 지우지 않고, 이미 만든 면접의 스냅샷도 그대로다. */
+ private int hide(Set hiddenCodes) {
+ if (hiddenCodes.isEmpty()) {
+ return 0;
+ }
int hidden = 0;
- for (CompanyEntity company : companyRepository.findAllByStatus(CompanyStatus.PUBLISHED)) {
- if (!readCodes.contains(company.getCode())) {
+ for (CompanyEntity company : companyRepository.findAllByCodeIn(hiddenCodes)) {
+ if (company.getStatus() != CompanyStatus.HIDDEN) {
company.setStatus(CompanyStatus.HIDDEN);
hidden++;
+ log.info("설정에 따라 기업을 숨겼습니다. code={}", company.getCode());
}
}
return hidden;
}
+ /** 공개 중인데 이번에 파일이 없는 기업. 숨기지 않는다 — 내리려면 hidden-codes에 넣는다. */
+ private void warnMissing(Set readCodes, Set hiddenCodes) {
+ List missing = companyRepository.findAllByStatus(CompanyStatus.PUBLISHED).stream()
+ .map(CompanyEntity::getCode)
+ .filter(code -> !readCodes.contains(code) && !hiddenCodes.contains(code))
+ .sorted()
+ .toList();
+ if (!missing.isEmpty()) {
+ log.warn("공개 중인 기업의 데이터 파일이 없습니다. 숨기지 않고 기존 정보로 둡니다. 내리려면 app.company.hidden-codes에 "
+ + "넣으세요. codes={}", missing);
+ }
+ }
+
+ // 출처가 모두 SAMPLE이면 개발용 샘플이다. 검증이 실제 출처에는 검수 기록을 요구하므로 둘은 겹치지 않는다.
+ static boolean isSample(CompanyDataPackage pkg) {
+ return pkg.sources() != null && !pkg.sources().isEmpty() && pkg.sources().stream()
+ .allMatch(source -> source != null && source.sourceType() == CompanySourceType.SAMPLE);
+ }
+
+ static Set parseCodes(String value) {
+ if (value == null || value.isBlank()) {
+ return Set.of();
+ }
+ return Arrays.stream(value.split(","))
+ .map(String::strip)
+ .filter(code -> !code.isEmpty())
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
private static String sha256(byte[] bytes) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
@@ -356,6 +471,5 @@ private record Loaded(String fileName, CompanyDataPackage pkg, String hash) {
private enum Outcome { CREATED, UPDATED, UNCHANGED }
public record Result(int created, int updated, int unchanged, int hidden, int skipped) {
- static final Result NONE = new Result(0, 0, 0, 0, 0);
}
}
diff --git a/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataValidator.java b/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataValidator.java
index 9a6ece1b..1db94d02 100644
--- a/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataValidator.java
+++ b/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataValidator.java
@@ -15,10 +15,13 @@
import static repit.repit_api_server.domain.company.CompanyLimits.length;
/**
- * 패키지 파일 하나에 적용하는 검증(01 §3 규칙 1~7, 9). 하나라도 어기면 그 파일만 건너뛴다.
+ * 패키지 파일 하나에 적용하는 검증(02 §3-2 규칙 1~5, 7). 하나라도 어기면 그 파일만 건너뛴다.
*
- * 묶음 전체를 봐야 하는 규칙(8 유일성, 10 모회사)은 모든 파일을 읽은 뒤 가져오기가 본다.
+ *
묶음 전체를 봐야 하는 규칙(6 유일성, 8 모회사)은 모든 파일을 읽은 뒤 가져오기가 본다. 모회사를 자기 자신으로 둔
+ * 파일도 여기서 막지 않는다 — 관계만 저장하지 않으면 되는 일이라 가져오기가 연결을 빼고 경고한다.
* 여기를 통과한 데이터는 분석 서버에서 422가 나지 않는다 — 상한이 분석 서버 계약과 같다.
+ *
+ *
오류 문구는 로그에 남는다. 파일의 값(주소·법인 식별번호·문장)은 싣지 않고 어느 필드가 어떤 규칙을 어겼는지만 쓴다.
*/
public final class CompanyDataValidator {
@@ -43,7 +46,7 @@ public static List validate(String fileName, CompanyDataPackage pkg) {
// 1. 필수 필드와 길이·개수 상한
if (pkg.code() == null || !CODE.matcher(pkg.code()).matches()) {
- errors.add("code는 소문자·숫자·하이픈 2~40자여야 합니다: " + pkg.code());
+ errors.add("code는 소문자·숫자·하이픈 2~40자여야 합니다.");
}
text(errors, "displayName", pkg.displayName(), CompanyLimits.DISPLAY_NAME, true);
text(errors, "legalName", pkg.legalName(), 100, true);
@@ -55,10 +58,6 @@ public static List validate(String fileName, CompanyDataPackage pkg) {
pattern(errors, "dartCorpCode", pkg.dartCorpCode(), DART_CORP_CODE, "8자리 숫자");
pattern(errors, "businessRegNo", pkg.businessRegNo(), BUSINESS_REG_NO, "10자리 숫자");
required(errors, "dataAsOf", pkg.dataAsOf());
- // 자기 자신을 모회사로 두면 목록에 자기 이름이 모회사로 나가고, 모회사를 따라가는 코드가 생기면 끝없이 돈다.
- if (pkg.parentCode() != null && pkg.parentCode().equals(pkg.code())) {
- errors.add("parentCode가 자기 code와 같습니다: " + pkg.code());
- }
if (pkg.review() != null) {
// 컬럼이 VARCHAR(50)이다. 넘으면 검증 오류 대신 저장 단계의 예외로만 드러난다.
text(errors, "review.reviewedBy", pkg.review().reviewedBy(), 50, false);
@@ -72,7 +71,7 @@ public static List validate(String fileName, CompanyDataPackage pkg) {
text(errors, at + "sourceKey", source.sourceKey(), 40, true);
required(errors, at + "sourceType", source.sourceType());
if (source.url() == null || !source.url().startsWith("https://")) {
- errors.add(at + "url은 https:// 주소여야 합니다: " + source.url());
+ errors.add(at + "url은 https:// 주소여야 합니다.");
}
text(errors, at + "title", source.title(), 200, false);
text(errors, at + "scope", source.scope(), 100, false);
@@ -108,15 +107,16 @@ public static List validate(String fileName, CompanyDataPackage pkg) {
// 4. 출처 연결
Set sourceKeys = new HashSet<>();
- for (CompanyDataPackage.Source source : sources) {
- if (source.sourceKey() != null && !sourceKeys.add(source.sourceKey())) {
- errors.add("sourceKey가 겹칩니다: " + source.sourceKey());
+ for (int i = 0; i < sources.size(); i++) {
+ String sourceKey = sources.get(i).sourceKey();
+ if (sourceKey != null && !sourceKeys.add(sourceKey)) {
+ errors.add("sourceKey가 겹칩니다: sources[" + i + "]");
}
}
for (int i = 0; i < facts.size(); i++) {
String sourceKey = facts.get(i).sourceKey();
if (sourceKey != null && !sourceKeys.contains(sourceKey)) {
- errors.add("facts[" + i + "].sourceKey가 sources에 없습니다: " + sourceKey);
+ errors.add("facts[" + i + "].sourceKey가 sources에 없습니다.");
}
}
@@ -176,6 +176,7 @@ private static void signals(List errors, String field, List sign
}
}
+ // 실행 입력은 앞뒤 공백을 뺀 뒤 센다(00 §2). 스냅샷도 같은 값을 싣는다.
private static void text(List errors, String field, String value, int max, boolean required) {
if (isBlank(value)) {
if (required) {
@@ -183,8 +184,8 @@ private static void text(List errors, String field, String value, int ma
}
return;
}
- if (length(value) > max) {
- errors.add(field + "는 " + max + "자 이하여야 합니다: " + length(value) + "자");
+ if (length(value.strip()) > max) {
+ errors.add(field + "는 " + max + "자 이하여야 합니다: " + length(value.strip()) + "자");
}
}
@@ -202,7 +203,7 @@ private static void required(List errors, String field, Object value) {
private static void pattern(List errors, String field, String value, Pattern pattern, String rule) {
if (value != null && !pattern.matcher(value).matches()) {
- errors.add(field + "는 " + rule + "여야 합니다: " + value);
+ errors.add(field + "는 " + rule + "여야 합니다.");
}
}
diff --git a/src/main/java/repit/repit_api_server/domain/company/repository/CompanyRepository.java b/src/main/java/repit/repit_api_server/domain/company/repository/CompanyRepository.java
index 75207359..71bc6bf6 100644
--- a/src/main/java/repit/repit_api_server/domain/company/repository/CompanyRepository.java
+++ b/src/main/java/repit/repit_api_server/domain/company/repository/CompanyRepository.java
@@ -1,7 +1,9 @@
package repit.repit_api_server.domain.company.repository;
+import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.EntityGraph;
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.data.repository.query.Param;
@@ -16,13 +18,26 @@ public interface CompanyRepository extends JpaRepository {
Optional findByCode(String code);
+ List findAllByCodeIn(Collection codes);
+
List findAllByStatus(CompanyStatus status);
// 목록에 모회사 이름을 함께 싣는다. 모회사를 기업마다 따로 읽지 않도록 한 번에 가져온다.
@EntityGraph(attributePaths = "parent")
List findWithParentByStatus(CompanyStatus status);
- Optional findByCompanyIdAndStatus(Long companyId, CompanyStatus status);
+ /**
+ * 스냅샷을 만들 때 기업 행을 공유 잠금(FOR SHARE)으로 읽는다. 가져오기는 같은 행을 FOR UPDATE로 잡고 기업·fact를
+ * 바꾸므로, 둘이 겹치면 한쪽이 커밋할 때까지 기다린다 — 이전 요약과 새 fact가 섞인 스냅샷이 생기지 않는다.
+ */
+ @Lock(LockModeType.PESSIMISTIC_READ)
+ @Query("select c from CompanyEntity c where c.companyId = :companyId")
+ Optional findForShareByCompanyId(@Param("companyId") Long companyId);
+
+ // 가져오기가 기존 기업을 고치기 전에 잡는다. 위 공유 잠금과 짝이다.
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select c from CompanyEntity c where c.code = :code")
+ Optional findForUpdateByCode(@Param("code") String code);
// 숨긴 기업이 들고 있는 식별자를 놓게 한다. 같은 법인을 다른 code로 다시 낸 패키지가 UNIQUE에 걸리지 않게 한다.
@Modifying(flushAutomatically = true, clearAutomatically = true)
diff --git a/src/main/java/repit/repit_api_server/domain/company/service/CompanyQueryService.java b/src/main/java/repit/repit_api_server/domain/company/service/CompanyQueryService.java
index 00c12655..cd303c31 100644
--- a/src/main/java/repit/repit_api_server/domain/company/service/CompanyQueryService.java
+++ b/src/main/java/repit/repit_api_server/domain/company/service/CompanyQueryService.java
@@ -24,7 +24,11 @@
/**
* 공개 기업 목록·검색.
*
- * 공개 기업이 30개 안팎이라 요청마다 전부 읽어 메모리에서 거른다. 캐시는 두지 않는다.
+ *
공개 기업이 30개 안팎이라 요청마다 전부 읽어 메모리에서 거른다. 캐시는 두지 않는다. 검색어를 SQL 패턴으로 넘기지 않으므로
+ * {@code %}·{@code _}도 글자 그대로 찾는다.
+ *
+ *
내보내는 기업은 {@link CompanyVisibility} 조건을 지킨다. 공개 기업이 없으면 빈 목록이고, 조회 장애는 예외(5xx)로 나가
+ * 빈 목록과 구분된다.
*/
@Service
@RequiredArgsConstructor
@@ -37,6 +41,7 @@ public class CompanyQueryService {
private final CompanyRepository companyRepository;
private final CompanyFactRepository companyFactRepository;
+ private final CompanyVisibility companyVisibility;
@Transactional(readOnly = true)
public List search(String q, CompanyCategory category) {
@@ -46,11 +51,14 @@ public List search(String q, CompanyCategory category) {
"검색어는 " + QUERY_MIN_LENGTH + "~" + QUERY_MAX_LENGTH + "자로 입력해주세요.");
}
+ // 정렬은 분류 선언 순서, 이름, companyId 순으로 고정한다. 이름이 같아도 요청마다 순서가 바뀌지 않는다.
List companies = companyRepository.findWithParentByStatus(CompanyStatus.PUBLISHED).stream()
+ .filter(companyVisibility::isSelectable)
.filter(company -> category == null || company.getCategory() == category)
.filter(company -> query.isEmpty() || matches(company, query))
.sorted(Comparator.comparing(CompanyEntity::getCategory)
- .thenComparing(CompanyEntity::getDisplayName))
+ .thenComparing(CompanyEntity::getDisplayName)
+ .thenComparing(CompanyEntity::getCompanyId))
.toList();
if (companies.isEmpty()) {
return List.of();
@@ -76,10 +84,10 @@ public List search(String q, CompanyCategory category) {
.toList();
}
- // 숨긴 모회사의 이름은 내보내지 않는다. 정보 수정·삭제 요청으로 내린 기업 이름이 계열사 카드에 계속 남는다.
- private static String parentDisplayName(CompanyEntity company) {
+ // 내보낼 수 없는 모회사의 이름은 싣지 않는다. 정보 수정·삭제 요청으로 내린 기업 이름이 계열사 카드에 계속 남는다.
+ private String parentDisplayName(CompanyEntity company) {
CompanyEntity parent = company.getParent();
- return parent == null || parent.getStatus() != CompanyStatus.PUBLISHED ? null : parent.getDisplayName();
+ return companyVisibility.isSelectable(parent) ? parent.getDisplayName() : null;
}
/** 보이는 이름, 법인 이름, 별칭 가운데 하나라도 검색어를 품으면 맞다. 공백·대소문자는 보지 않는다. */
diff --git a/src/main/java/repit/repit_api_server/domain/company/service/CompanySnapshotFactory.java b/src/main/java/repit/repit_api_server/domain/company/service/CompanySnapshotFactory.java
index 9f689ed5..9f80928d 100644
--- a/src/main/java/repit/repit_api_server/domain/company/service/CompanySnapshotFactory.java
+++ b/src/main/java/repit/repit_api_server/domain/company/service/CompanySnapshotFactory.java
@@ -1,87 +1,153 @@
package repit.repit_api_server.domain.company.service;
import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import repit.repit_api_server.domain.company.CompanyLimits;
import repit.repit_api_server.domain.company.dto.CompanySnapshot;
import repit.repit_api_server.domain.company.entity.CompanyEntity;
import repit.repit_api_server.domain.company.entity.CompanyFactEntity;
import repit.repit_api_server.domain.company.entity.enums.CompanyFactKind;
-import repit.repit_api_server.domain.company.entity.enums.CompanyStatus;
import repit.repit_api_server.domain.company.repository.CompanyFactRepository;
import repit.repit_api_server.domain.company.repository.CompanyRepository;
+import repit.repit_api_server.global.error.ErrorCodes;
import repit.repit_api_server.global.exception.BusinessException;
+import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
import java.util.Set;
-import java.util.stream.Collectors;
import static repit.repit_api_server.domain.company.CompanyLimits.length;
-/** 면접을 만들 때 공개 기업을 스냅샷으로 복사한다. 이후 면접의 모든 경로는 이 스냅샷만 읽는다. */
+/**
+ * 면접을 만들 때 공개 기업을 스냅샷으로 복사한다. 이후 면접의 모든 경로는 이 스냅샷만 읽는다.
+ *
+ * 면접 생성 트랜잭션에 합류한다. 기업 행을 FOR SHARE로 잡고 fact를 읽으므로, 같은 기업을 갱신하는 가져오기(FOR UPDATE)와
+ * 겹치면 한쪽이 끝날 때까지 기다린다. 면접 생성 트랜잭션은 외부 서버를 부르지 않아 잠금을 오래 쥐지 않는다.
+ */
@Component
@RequiredArgsConstructor
public class CompanySnapshotFactory {
+ private static final Logger log = LoggerFactory.getLogger(CompanySnapshotFactory.class);
+
+ static final String UNAVAILABLE_MESSAGE = "선택한 기업을 찾을 수 없습니다. 기업을 다시 골라주세요.";
+ static final String CONTEXT_UNAVAILABLE_MESSAGE =
+ "선택한 기업의 면접 정보를 지금 준비할 수 없습니다. 다른 기업을 고르거나 기업 없이 시작해주세요.";
+ private static final int FACT_ID_MAX = 32;
+
private final CompanyRepository companyRepository;
private final CompanyFactRepository companyFactRepository;
+ private final CompanyVisibility companyVisibility;
- // 기업 행과 fact를 두 문장으로 읽는다. 그 사이 가져오기가 커밋되면 이전 요약과 새 fact가 섞인 스냅샷이 면접에 영구히
- // 남는다. REPEATABLE READ면 두 문장이 같은 시점을 본다.
- @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
+ @Transactional
public CompanySnapshot create(Long companyId) {
- CompanyEntity company = companyRepository.findByCompanyIdAndStatus(companyId, CompanyStatus.PUBLISHED)
- .orElseThrow(() -> BusinessException.notFound("선택한 기업을 찾을 수 없습니다."));
+ CompanyEntity company = companyRepository.findForShareByCompanyId(companyId)
+ .filter(companyVisibility::isSelectable)
+ .orElseThrow(() -> new BusinessException(UNAVAILABLE_MESSAGE, HttpStatus.NOT_FOUND,
+ ErrorCodes.COMPANY_UNAVAILABLE));
List facts = companyFactRepository.findAllByCompanyIdOrderBySortOrder(companyId).stream()
- .map(this::toFact)
+ .map(CompanySnapshotFactory::toFact)
.toList();
CompanySnapshot snapshot = new CompanySnapshot(String.valueOf(company.getCompanyId()),
- company.getDisplayName(), company.getCategory().name(), facts, company.getInterviewerBrief());
- verifyLimits(snapshot);
+ strip(company.getDisplayName()), company.getCategory() == null ? null : company.getCategory().name(),
+ facts, strip(company.getInterviewerBrief()));
+
+ List violations = violations(snapshot);
+ if (!violations.isEmpty()) {
+ // 데이터 원문은 남기지 않는다. 어느 규칙인지만 남겨 데이터를 고친다.
+ log.error("공개 기업의 실행 스냅샷을 만들 수 없어 면접 생성을 막습니다. companyId={}, 사유={}",
+ companyId, violations);
+ throw new BusinessException(CONTEXT_UNAVAILABLE_MESSAGE, HttpStatus.SERVICE_UNAVAILABLE,
+ ErrorCodes.COMPANY_CONTEXT_UNAVAILABLE);
+ }
return snapshot;
}
- private CompanySnapshot.Fact toFact(CompanyFactEntity fact) {
+ // 신호는 TALENT에만 싣는다. kind·title·content·id는 저장된 값 그대로이고 비었다고 채우지 않는다.
+ private static CompanySnapshot.Fact toFact(CompanyFactEntity fact) {
boolean talent = fact.getKind() == CompanyFactKind.TALENT;
- return new CompanySnapshot.Fact(String.valueOf(fact.getFactId()), fact.getKind().name(),
- fact.getTitle(), fact.getContent(),
- talent ? fact.getPositiveSignals() : List.of(),
- talent ? fact.getConcernSignals() : List.of());
+ return new CompanySnapshot.Fact(String.valueOf(fact.getFactId()),
+ fact.getKind() == null ? null : fact.getKind().name(),
+ strip(fact.getTitle()), strip(fact.getContent()),
+ talent ? stripAll(fact.getPositiveSignals()) : List.of(),
+ talent ? stripAll(fact.getConcernSignals()) : List.of());
}
/**
- * 분석 서버 계약(00 §6-1). 어기면 분석 서버가 재작성·피드백 요청을 422로 거부한다. 스냅샷은 면접에 고정되므로,
+ * 분석 서버 계약(00 §2). 어기면 분석 서버가 재작성·피드백 요청을 422로 거부한다. 스냅샷은 면접에 고정되므로,
* 어긴 채 만든 면접은 다시 시도해도 같은 422라 영영 시작하지 못한다. 그래서 면접을 만들기 전에 막는다.
* 가져오기 검증을 통과한 데이터라면 생기지 않으므로, 생겼다면 데이터를 고칠 일이다.
+ *
+ * 사유는 로그에 남기는 규칙 이름이다. 값은 싣지 않는다.
*/
- private void verifyLimits(CompanySnapshot snapshot) {
- Set kinds = snapshot.facts().stream().map(CompanySnapshot.Fact::kind).collect(Collectors.toSet());
- boolean within = within(snapshot.displayName(), CompanyLimits.DISPLAY_NAME)
- && within(snapshot.interviewerBrief(), CompanyLimits.INTERVIEWER_BRIEF)
- && !snapshot.facts().isEmpty()
- && snapshot.facts().size() <= CompanyLimits.FACTS
- && kinds.contains(CompanyFactKind.TALENT.name())
- && kinds.contains(CompanyFactKind.BUSINESS.name())
- && snapshot.facts().stream().allMatch(fact ->
- within(fact.title(), CompanyLimits.FACT_TITLE)
- && within(fact.content(), CompanyLimits.FACT_CONTENT)
- && withinSignals(fact.positiveSignals())
- && withinSignals(fact.concernSignals()));
- if (!within) {
- throw new IllegalStateException("기업 정보가 분석 서버 계약에 맞지 않습니다. companyId=" + snapshot.companyId());
+ static List violations(CompanySnapshot snapshot) {
+ List violations = new ArrayList<>();
+ if (!within(snapshot.displayName(), CompanyLimits.DISPLAY_NAME)) {
+ violations.add("displayName");
+ }
+ if (snapshot.category() == null) {
+ violations.add("category");
+ }
+ if (!within(snapshot.interviewerBrief(), CompanyLimits.INTERVIEWER_BRIEF)) {
+ violations.add("interviewerBrief");
+ }
+ List facts = snapshot.facts();
+ if (facts.isEmpty() || facts.size() > CompanyLimits.FACTS) {
+ violations.add("facts.size");
}
+ Set kinds = new HashSet<>();
+ Set factIds = new HashSet<>();
+ for (int i = 0; i < facts.size(); i++) {
+ CompanySnapshot.Fact fact = facts.get(i);
+ String at = "facts[" + i + "].";
+ kinds.add(fact.kind());
+ if (!within(fact.factId(), FACT_ID_MAX) || !factIds.add(fact.factId())) {
+ violations.add(at + "factId");
+ }
+ if (fact.kind() == null) {
+ violations.add(at + "kind");
+ }
+ if (!within(fact.title(), CompanyLimits.FACT_TITLE)) {
+ violations.add(at + "title");
+ }
+ if (!within(fact.content(), CompanyLimits.FACT_CONTENT)) {
+ violations.add(at + "content");
+ }
+ if (!withinSignals(fact.positiveSignals()) || !withinSignals(fact.concernSignals())) {
+ violations.add(at + "signals");
+ }
+ }
+ if (!kinds.contains(CompanyFactKind.BUSINESS.name())) {
+ violations.add("facts.BUSINESS");
+ }
+ if (!kinds.contains(CompanyFactKind.TALENT.name())) {
+ violations.add("facts.TALENT");
+ }
+ return violations;
}
- private boolean withinSignals(List signals) {
- return signals.size() <= CompanyLimits.SIGNALS_PER_FACT
+ private static boolean withinSignals(List signals) {
+ return signals != null && signals.size() <= CompanyLimits.SIGNALS_PER_FACT
&& signals.stream().allMatch(signal -> within(signal, CompanyLimits.SIGNAL));
}
- /** 1자 이상, 상한 이하. 분석 서버는 빈 문자열도 거부한다. */
+ /** 1자 이상, 상한 이하. 앞뒤 공백을 뺀 값을 code point로 센다(분석 서버 Python len과 같다). */
private static boolean within(String value, int max) {
return value != null && !value.isBlank() && length(value) <= max;
}
+
+ // 앞뒤 공백만 뺀다. 검수한 내용을 길이에 맞춰 자르지 않는다.
+ private static String strip(String value) {
+ return value == null ? null : value.strip();
+ }
+
+ private static List stripAll(List values) {
+ return values == null ? null : values.stream().map(CompanySnapshotFactory::strip).toList();
+ }
}
diff --git a/src/main/java/repit/repit_api_server/domain/company/service/CompanyVisibility.java b/src/main/java/repit/repit_api_server/domain/company/service/CompanyVisibility.java
new file mode 100644
index 00000000..81c1c782
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/company/service/CompanyVisibility.java
@@ -0,0 +1,38 @@
+package repit.repit_api_server.domain.company.service;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import repit.repit_api_server.domain.company.entity.CompanyEntity;
+import repit.repit_api_server.domain.company.entity.enums.CompanyStatus;
+
+/**
+ * 기업을 목록·신규 면접에 내보낼 수 있는지 정한다. 목록, 면접 생성 스냅샷, 모회사 이름이 같은 조건을 쓴다.
+ *
+ * 공개(PUBLISHED)이면서 사람이 검수한 기록이 있어야 한다. SAMPLE 패키지는 검수 기록이 없어도 되지만, 허용한 환경
+ * (개발·스테이징)에서만 내보낸다. 가져오기도 같은 설정으로 SAMPLE을 막지만, 설정을 바꾸기 전에 이미 DB에 들어온 SAMPLE이
+ * 운영 목록에 남지 않도록 여기서도 본다.
+ */
+@Component
+public class CompanyVisibility {
+
+ private final boolean allowSample;
+
+ public CompanyVisibility(@Value("${app.company.allow-sample:false}") boolean allowSample) {
+ this.allowSample = allowSample;
+ }
+
+ public boolean allowsSample() {
+ return allowSample;
+ }
+
+ public boolean isSelectable(CompanyEntity company) {
+ if (company == null || company.getStatus() != CompanyStatus.PUBLISHED) {
+ return false;
+ }
+ if (company.isSample()) {
+ return allowSample;
+ }
+ return company.getReviewedBy() != null && !company.getReviewedBy().isBlank()
+ && company.getReviewedAt() != null;
+ }
+}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CompanyFitResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CompanyFitResponse.java
index e0c2d8bf..bed87208 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CompanyFitResponse.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CompanyFitResponse.java
@@ -5,8 +5,9 @@
/**
* 피드백의 "○○ 기준으로 보면" 섹션(00 §6-2). 점수와 무관하다 — 종합 점수와 산출 근거 계산에 들어가지 않는다.
*
- *
kind·focus는 분석 서버가 요청의 fact에서 복사해 채운다. focus는 fact의 title이다.
- * questionIds는 분석 서버에 보낸 질문 id 그대로라, 웹은 feedbacks[].questionId와 맞춰 문항을 잇는다.
+ *
kind·focus는 서버가 면접 스냅샷의 fact에서 채운다(모델 값은 쓰지 않는다). focus는 fact의 title이다.
+ * questionIds는 분석 서버에 보낸 질문 id 그대로라, 웹은 feedbacks[].questionId와 맞춰 문항을 잇는다. 콜백을 저장할 때 답변 있는
+ * 문항이 하나도 남지 않는 항목은 뺀다({@code CompanyFitReader}). 그 전에 저장된 결과에는 빈 목록인 항목이 남아 있을 수 있다.
*/
public record CompanyFitResponse(String comment, List- matchedFocus, List gaps) {
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackResponse.java
index 8c435241..a6161568 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackResponse.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackResponse.java
@@ -81,6 +81,9 @@ public static FeedbackResponse of(FeedbackEntity feedback,
PersonaEntity persona,
List personas,
List items) {
+ CompanyBadge company = interview == null
+ ? null
+ : CompanyBadge.of(interview.getCompanyId(), interview.getCompanySnapshot());
return FeedbackResponse.builder()
.feedbackId(feedback.getFeedbackId())
.interviewId(feedback.getInterviewId())
@@ -103,8 +106,9 @@ public static FeedbackResponse of(FeedbackEntity feedback,
.personas(personas.stream().map(FeedbackPersonaResponse::from).toList())
.feedbacks(items.stream().map(FeedbackItemResponse::from).toList())
.createdAt(feedback.getCreatedAt())
- .company(interview == null ? null : CompanyBadge.of(interview.getCompanyId(), interview.getCompanySnapshot()))
- .companyFit(feedback.getCompanyFit())
+ .company(company)
+ // 기업을 고르지 않은 면접에는 기업 코멘트를 잇지 않는다. 배지와 코멘트는 함께 있거나 함께 비어 있다.
+ .companyFit(company == null ? null : feedback.getCompanyFit())
.build();
}
}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackEntity.java
index de3aa67f..acb400b7 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackEntity.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackEntity.java
@@ -81,6 +81,12 @@ public class FeedbackEntity {
@Column(columnDefinition = "jsonb")
private CompanyFitResponse companyFit;
+ // 이 채점 작업에 실어 보낸 문항 가운데 답변이 있는 것의 id. 기업 관점 코멘트가 근거로 댈 수 있는 목록이다.
+ // 접수할 때 한 번 넣고 엔티티 저장으로는 바꾸지 않는다. 콜백이 먼저 와 만든 행은 FeedbackRepository가 채운다.
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(columnDefinition = "jsonb", updatable = false)
+ private List requestedQuestionIds;
+
private Integer answeredCount;
private Integer questionCount;
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackRepository.java
index d5b2d3fb..d131b295 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackRepository.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackRepository.java
@@ -1,6 +1,10 @@
package repit.repit_api_server.domain.userdata.feedback.repository;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.transaction.annotation.Transactional;
import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackEntity;
import java.util.List;
@@ -16,4 +20,18 @@ public interface FeedbackRepository extends JpaRepository
// 사용자의 피드백 전체. 최근 것부터 본다.
List findAllByUserIdOrderByCreatedAtDesc(Long userId);
+
+ /**
+ * 콜백이 접수 응답보다 먼저 와 만든 행에 이 작업이 보낸 문항 목록을 채운다. 이미 있으면 두지 않는다. 엔티티 저장으로는 이
+ * 열을 바꾸지 않으므로(updatable = false) 콜백이 같은 행을 다시 저장해도 지워지지 않는다.
+ */
+ @Transactional
+ @Modifying
+ @Query(value = """
+ update feedback
+ set requested_question_ids = cast(:questionIds as jsonb)
+ where feedback_id = :feedbackId
+ and requested_question_ids is null
+ """, nativeQuery = true)
+ int recordRequestedQuestionIds(@Param("feedbackId") Long feedbackId, @Param("questionIds") String questionIdsJson);
}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReader.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReader.java
index 0c71ecb6..cb151cda 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReader.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReader.java
@@ -2,41 +2,195 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import repit.repit_api_server.domain.company.dto.CompanySnapshot;
import repit.repit_api_server.domain.userdata.feedback.dto.response.CompanyFitResponse;
-import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
-import tools.jackson.databind.json.JsonMapper;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static repit.repit_api_server.domain.company.CompanyLimits.length;
/**
- * 채점 콜백의 companyFit을 읽는다.
+ * 채점 콜백의 companyFit을 그 면접의 스냅샷과 그 작업의 대상 문항에 대조해 읽는다(00 §6).
+ *
+ *
{@link ScoreBreakdownReader}와 같은 이유로 요청 본문에서는 모양을 가리지 않고 받아 여기서 푼다. 타입을 정해 받으면
+ * 모양이 하나만 어긋나도 본문 전체를 읽지 못해 채점 결과까지 잃는다. 기업 관점 코멘트는 없어도 피드백이 성립하는 부가
+ * 정보라, 받아들일 수 없으면 null로 두고 기본 피드백은 그대로 저장한다.
*
- *
{@link ScoreBreakdownReader}와 같은 이유로 요청 본문에서는 모양을 가리지 않고 받아 여기서 푼다. 타입을 정해
- * 받으면 모양이 하나만 어긋나도 본문 전체를 읽지 못해 채점 결과까지 잃는다. 기업 관점 코멘트는 없어도 피드백이
- * 성립하는 부가 정보라, 읽지 못하면 비워 두고 어긋난 사실을 로그로 남긴다.
+ *
+ * - 기업을 고르지 않은 면접, null, 모양이 틀린 값은 null이다.
+ * - comment는 앞뒤 공백을 뺀 1~300자. 비었거나 넘으면 전체 null이다 — 잘라 저장하지 않는다.
+ * - 항목은 스냅샷의 factId만 받는다. kind·focus는 모델 값을 버리고 스냅샷 fact의 kind·title로 채운다.
+ * - evidence·suggestion은 1~150자. 어기면 그 항목을 뺀다.
+ * - questionIds는 그 작업에 보낸 답변 있는 문항만 남기고 겹친 것을 뺀다. 하나도 안 남으면 그 항목을 뺀다.
+ * - 한 목록 안에서 같은 factId는 첫 유효 항목만 둔다. matchedFocus와 gaps가 같은 fact를 가리키는 것은 괜찮다.
+ * - 목록마다 유효 항목 앞의 세 개까지 둔다.
+ * - 항목이 있었는데 모두 빠지면 전체 null이다 — 근거 없는 총평만 살리지 않는다. 처음부터 두 목록이 비었으면 총평만 둔다.
+ *
+ *
+ * 로그에는 면접 id와 사유·개수만 남긴다. 모델이 쓴 문장은 남기지 않는다.
*/
final class CompanyFitReader {
private static final Logger log = LoggerFactory.getLogger(CompanyFitReader.class);
+ static final int COMMENT_MAX = 300;
+ static final int DETAIL_MAX = 150;
+ static final int ITEMS_MAX = 3;
+
private CompanyFitReader() {
}
- static CompanyFitResponse read(JsonNode node, Long interviewId) {
+ /**
+ * @param snapshot 그 면접의 기업 스냅샷. 피드백 요청에 실은 것과 같다. 기업을 고르지 않았으면 null이다.
+ * @param answeredQuestionIds 그 작업에 보낸 문항 가운데 답변이 있는 것의 id. 보낸 문자열 그대로다.
+ */
+ static CompanyFitResponse read(JsonNode node, CompanySnapshot snapshot, Set answeredQuestionIds,
+ Long interviewId) {
// 기업을 고르지 않은 면접이거나, 분석 서버가 만들지 못했으면 null로 온다.
if (node == null || node.isNull() || node.isMissingNode()) {
return null;
}
- try {
- CompanyFitResponse fit = JsonMapper.shared().treeToValue(node, CompanyFitResponse.class);
- if (fit.comment() == null || fit.comment().isBlank()) {
- log.warn("기업 관점 코멘트가 비어 있어 비워 둡니다. interviewId={}, 받은 값={}", interviewId, node);
- return null;
+ if (snapshot == null) {
+ log.warn("기업을 고르지 않은 면접에 기업 관점 코멘트가 와서 버립니다. interviewId={}", interviewId);
+ return null;
+ }
+ if (!node.isObject()) {
+ return rejected(interviewId, "NOT_OBJECT");
+ }
+
+ String comment = text(node.get("comment"));
+ if (comment == null || comment.isEmpty()) {
+ return rejected(interviewId, "EMPTY_COMMENT");
+ }
+ if (length(comment) > COMMENT_MAX) {
+ return rejected(interviewId, "COMMENT_TOO_LONG");
+ }
+
+ JsonNode matchedNode = node.get("matchedFocus");
+ JsonNode gapsNode = node.get("gaps");
+ if (!isListOrAbsent(matchedNode) || !isListOrAbsent(gapsNode)) {
+ return rejected(interviewId, "NOT_LIST");
+ }
+
+ Map facts = snapshot.facts().stream()
+ .collect(Collectors.toMap(CompanySnapshot.Fact::factId, Function.identity(), (a, b) -> a));
+ Map dropped = new TreeMap<>();
+ List matched = readItems(matchedNode, "evidence", facts, answeredQuestionIds, dropped,
+ (fact, detail, ids) -> new CompanyFitResponse.Item(fact.factId(), fact.kind(), fact.title(), detail, ids));
+ List gaps = readItems(gapsNode, "suggestion", facts, answeredQuestionIds, dropped,
+ (fact, detail, ids) -> new CompanyFitResponse.Gap(fact.factId(), fact.kind(), fact.title(), detail, ids));
+
+ int received = size(matchedNode) + size(gapsNode);
+ if (received > 0 && matched.isEmpty() && gaps.isEmpty()) {
+ log.warn("기업 관점 코멘트의 항목이 모두 근거가 맞지 않아 코멘트 전체를 비워 둡니다. interviewId={}, 받은 항목={}, 사유={}",
+ interviewId, received, dropped);
+ return null;
+ }
+ if (!dropped.isEmpty()) {
+ log.warn("기업 관점 코멘트에서 근거가 맞지 않는 항목을 뺐습니다. interviewId={}, 받은 항목={}, 남은 항목={}, 사유={}",
+ interviewId, received, matched.size() + gaps.size(), dropped);
+ }
+ return new CompanyFitResponse(comment, matched, gaps);
+ }
+
+ private interface ItemFactory {
+ T create(CompanySnapshot.Fact fact, String detail, List questionIds);
+ }
+
+ private static List readItems(JsonNode list, String detailField, Map facts,
+ Set answeredQuestionIds, Map dropped,
+ ItemFactory factory) {
+ List items = new ArrayList<>();
+ if (list == null || list.isNull()) {
+ return items;
+ }
+ Set usedFactIds = new HashSet<>();
+ for (JsonNode element : list) {
+ String reason = null;
+ CompanySnapshot.Fact fact = null;
+ String detail = null;
+ List questionIds = List.of();
+ if (element == null || !element.isObject()) {
+ reason = "ITEM_NOT_OBJECT";
+ } else {
+ fact = facts.get(id(element.get("factId")));
+ detail = text(element.get(detailField));
+ questionIds = questionIds(element.get("questionIds"), answeredQuestionIds);
+ if (fact == null) {
+ reason = "UNKNOWN_FACT";
+ } else if (detail == null || detail.isEmpty() || length(detail) > DETAIL_MAX) {
+ reason = "INVALID_" + detailField.toUpperCase(Locale.ROOT);
+ } else if (questionIds.isEmpty()) {
+ reason = "NO_ANSWERED_QUESTION";
+ } else if (usedFactIds.contains(fact.factId())) {
+ reason = "DUPLICATE_FACT";
+ } else if (items.size() >= ITEMS_MAX) {
+ reason = "TOO_MANY";
+ }
+ }
+ if (reason != null) {
+ dropped.merge(reason, 1, Integer::sum);
+ continue;
}
- return fit;
- } catch (JacksonException e) {
- log.warn("기업 관점 코멘트를 읽지 못해 비워 둡니다. interviewId={}, 받은 값={}, 오류={}",
- interviewId, node, e.getOriginalMessage());
+ usedFactIds.add(fact.factId());
+ items.add(factory.create(fact, detail, questionIds));
+ }
+ return items;
+ }
+
+ // 보낸 문항 id 그대로 비교한다. "q2"를 숫자로 바꾸거나 양수만 받지 않는다. 순서는 받은 대로 두고 겹친 것만 뺀다.
+ private static List questionIds(JsonNode node, Set answeredQuestionIds) {
+ if (node == null || !node.isArray()) {
+ return List.of();
+ }
+ Set kept = new LinkedHashSet<>();
+ for (JsonNode element : node) {
+ String questionId = id(element);
+ if (questionId != null && answeredQuestionIds.contains(questionId)) {
+ kept.add(questionId);
+ }
+ }
+ return List.copyOf(kept);
+ }
+
+ // id는 문자열 계약이지만 정수로 와도 같은 글자로 받는다.
+ private static String id(JsonNode node) {
+ if (node == null) {
return null;
}
+ if (node.isString()) {
+ return node.stringValue();
+ }
+ if (node.isIntegralNumber()) {
+ return node.asString();
+ }
+ return null;
+ }
+
+ private static String text(JsonNode node) {
+ return node != null && node.isString() ? node.stringValue().strip() : null;
+ }
+
+ private static boolean isListOrAbsent(JsonNode node) {
+ return node == null || node.isNull() || node.isArray();
+ }
+
+ private static int size(JsonNode node) {
+ return node != null && node.isArray() ? node.size() : 0;
+ }
+
+ private static CompanyFitResponse rejected(Long interviewId, String reason) {
+ log.warn("기업 관점 코멘트를 받아들일 수 없어 비워 둡니다. interviewId={}, 사유={}", interviewId, reason);
+ return null;
}
}
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 9f08dd5b..f749652c 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
@@ -7,6 +7,7 @@
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import repit.repit_api_server.domain.company.dto.CompanySnapshot;
import repit.repit_api_server.domain.userdata.feedback.dto.request.FeedbackCallbackRequest;
import repit.repit_api_server.domain.userdata.feedback.dto.request.FeedbackMultiRequest;
import repit.repit_api_server.domain.userdata.feedback.dto.request.FeedbackSoloRequest;
@@ -35,6 +36,9 @@
import repit.repit_api_server.domain.userdata.question.repository.QuestionRepository;
import repit.repit_api_server.global.client.AiServerClient;
import repit.repit_api_server.global.exception.BusinessException;
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.json.JsonMapper;
import java.time.Duration;
import java.time.LocalDateTime;
@@ -152,16 +156,44 @@ private FeedbackStatus runningStatusOf(Long interviewId) {
/** 분석 서버에 채점을 맡기고 접수 사실을 남긴다. 웹이 부르는 길과 채팅 서버가 부르는 길이 함께 쓴다. */
private FeedbackAcceptedResponse startFeedback(InterviewEntity interview) {
+ Transcript transcript = loadTranscript(interview);
// N:1은 면접관별 평가까지 받아야 해서 엔드포인트가 다르다. 1:1 채점에 태우면
// 질문이 누구 것인지 잃고 personas 블록도 오지 않는다.
- FeedbackAcceptedResponse accepted = interview.getMode() == InterviewMode.MULTI
- ? aiServerClient.requestMultiFeedback(toMultiRequest(interview))
- : aiServerClient.requestSoloFeedback(toSoloRequest(interview));
+ FeedbackMultiRequest multi = interview.getMode() == InterviewMode.MULTI ? toMultiRequest(interview, transcript) : null;
+ FeedbackSoloRequest solo = multi == null ? toSoloRequest(interview, transcript) : null;
+
+ // 기업 관점 코멘트의 근거는 이 요청에 실은 답변 있는 문항뿐이다. 콜백은 접수 응답보다 먼저 올 수 있어 보내기 전에 남긴다.
+ List requestedQuestionIds = answeredQuestionIds(transcript);
+ interviewRepository.recordLastFeedbackQuestionIds(interview.getInterviewId(), toJson(requestedQuestionIds));
- saveAccepted(interview, accepted == null ? null : accepted.getJobId());
+ FeedbackAcceptedResponse accepted = multi != null
+ ? aiServerClient.requestMultiFeedback(multi)
+ : aiServerClient.requestSoloFeedback(solo);
+
+ saveAccepted(interview, accepted == null ? null : accepted.getJobId(), requestedQuestionIds);
return accepted;
}
+ /**
+ * 채점 요청에 실은 문항 가운데 내용 있는 답변이 붙은 것의 id. 요청에 실은 문자열 그대로이고 면접 진행 순서다.
+ * 기업 관점 코멘트는 이 목록에 있는 문항만 근거로 댈 수 있다.
+ */
+ private static List answeredQuestionIds(Transcript transcript) {
+ Set answered = transcript.answers().stream()
+ .filter(answer -> answer.getContent() != null && !answer.getContent().isBlank())
+ .map(AnswerEntity::getQuestionId)
+ .collect(Collectors.toSet());
+ return transcript.questions().stream()
+ .map(QuestionEntity::getQuestionId)
+ .filter(answered::contains)
+ .map(String::valueOf)
+ .toList();
+ }
+
+ private static String toJson(List questionIds) {
+ return JsonMapper.shared().writeValueAsString(questionIds);
+ }
+
/**
* 접수 사실을 남긴다.
*
@@ -173,10 +205,13 @@ private FeedbackAcceptedResponse startFeedback(InterviewEntity interview) {
* 없었더라도 결과가 빈 PENDING 행이 최신이 되어, 조회가 그것을 집어 들고 이미 받아둔 결과를
* 가린다. 그래서 같은 작업의 행이 이미 있으면 그대로 둔다.
*/
- private void saveAccepted(InterviewEntity interview, String jobId) {
- if (jobId != null && feedbackRepository.findByJobId(jobId).isPresent()) {
+ private void saveAccepted(InterviewEntity interview, String jobId, List requestedQuestionIds) {
+ FeedbackEntity early = jobId == null ? null : feedbackRepository.findByJobId(jobId).orElse(null);
+ if (early != null) {
log.info("채점 결과가 접수 응답보다 먼저 도착해 이미 기록돼 있습니다. interviewId={}, jobId={}",
interview.getInterviewId(), jobId);
+ // 그 콜백은 보내기 전에 남긴 같은 목록으로 근거를 가렸다. 같은 콜백이 다시 와도 이 작업의 목록을 쓰도록 채워 둔다.
+ feedbackRepository.recordRequestedQuestionIds(early.getFeedbackId(), toJson(requestedQuestionIds));
return;
}
@@ -187,6 +222,7 @@ private void saveAccepted(InterviewEntity interview, String jobId) {
.sessionId(interview.getSessionId())
.jobId(jobId)
.status(FeedbackStatus.PENDING)
+ .requestedQuestionIds(requestedQuestionIds)
.build());
} catch (DataIntegrityViolationException e) {
// 확인과 저장 사이에 콜백이 끼어든 경우다. 결과는 콜백이 이미 남겼으니 접수만 넘어간다.
@@ -251,8 +287,7 @@ private Transcript loadTranscript(InterviewEntity interview) {
return new Transcript(questions, answers, questionIds);
}
- private FeedbackSoloRequest toSoloRequest(InterviewEntity interview) {
- Transcript transcript = loadTranscript(interview);
+ private FeedbackSoloRequest toSoloRequest(InterviewEntity interview, Transcript transcript) {
PersonaEntity persona = soloPersonaOf(interview);
return FeedbackSoloRequest.builder()
@@ -284,8 +319,7 @@ private FeedbackSoloRequest toSoloRequest(InterviewEntity interview) {
* 명단이 있어야 담당 문항이 없는 면접관도 결과에 자리를 얻고, 질문별 면접관이 있어야
* 면접관별 평가가 나뉜다.
*/
- private FeedbackMultiRequest toMultiRequest(InterviewEntity interview) {
- Transcript transcript = loadTranscript(interview);
+ private FeedbackMultiRequest toMultiRequest(InterviewEntity interview, Transcript transcript) {
List members = orderedPersonas(interview.getInterviewId());
Map personaByQuestion = resolveQuestionPersonas(transcript, members.getFirst().getPersonaId());
@@ -507,7 +541,7 @@ public void handleCallback(FeedbackCallbackRequest request) {
}
if (STATUS_SUCCEEDED.equalsIgnoreCase(request.getStatus()) && request.getResult() != null) {
- applySuccess(feedback, request.getResult());
+ applySuccess(feedback, request.getResult(), request.getJobId());
} else {
applyFailure(feedback, request.getError());
}
@@ -569,7 +603,7 @@ private FeedbackEntity createFromSession(String sessionId) {
* 시도한 뒤 결과를 영구 폐기하고, 사용자는 면접을 다 보고도 채점을 영영 받지 못한다.
* 어긋난 사실은 로그로 남겨 원인을 좇는다.
*/
- private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Result result) {
+ private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Result result, String callbackJobId) {
Long interviewId = feedback.getInterviewId();
RecordedCounts counts = recordedCounts(interviewId);
Set members = interviewMembers(interviewId);
@@ -599,8 +633,12 @@ private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Resul
feedback.setQuestionCount(counts.questionCountOr(overall.getQuestionCount(),
"면접 전체 문항 수", interviewId));
}
- // 기업 관점 코멘트. 읽지 못해도 피드백은 그대로 저장한다.
- feedback.setCompanyFit(CompanyFitReader.read(result.getCompanyFit(), interviewId));
+ // 기업 관점 코멘트. 그 면접의 스냅샷과 이 작업에 보낸 답변 있는 문항에 맞지 않으면 비워 두고, 피드백은 그대로 저장한다.
+ CompanySnapshot snapshot = interviewRepository.findById(interviewId)
+ .map(InterviewEntity::getCompanySnapshot)
+ .orElse(null);
+ feedback.setCompanyFit(CompanyFitReader.read(result.getCompanyFit(), snapshot,
+ requestedQuestionIds(feedback, callbackJobId), interviewId));
feedback.setStatus(FeedbackStatus.SUCCEEDED);
feedback.setErrorStatusCode(null);
feedback.setErrorMessage(null);
@@ -613,6 +651,35 @@ private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Resul
feedbackItemRepository.saveAll(itemRows);
}
+ /**
+ * 이 콜백의 기업 관점 코멘트가 근거로 댈 수 있는 문항. 콜백 시점의 면접 기록에서 다시 뽑지 않는다 — 요청 뒤 답변 API로
+ * 더해진 답변의 문항이 근거로 통과한다.
+ *
+ *
+ * - 이 작업의 접수 행이면 접수할 때 남긴 목록이다.
+ * - 콜백이 접수 응답보다 먼저 와 행에 이 작업의 목록이 없으면, 보내기 직전에 면접에 남긴 목록이다.
+ * - 둘 다 없으면(목록을 남기기 전에 보낸 요청) 어느 문항도 근거로 인정하지 않는다.
+ *
+ */
+ private Set requestedQuestionIds(FeedbackEntity feedback, String callbackJobId) {
+ if (feedback.getRequestedQuestionIds() != null && Objects.equals(feedback.getJobId(), callbackJobId)) {
+ return new LinkedHashSet<>(feedback.getRequestedQuestionIds());
+ }
+ String latest = interviewRepository.findLastFeedbackQuestionIds(feedback.getInterviewId()).orElse(null);
+ if (latest == null) {
+ log.warn("채점 요청에 보낸 문항 목록이 없어 기업 관점 코멘트의 근거를 인정하지 않습니다. interviewId={}, jobId={}",
+ feedback.getInterviewId(), callbackJobId);
+ return Set.of();
+ }
+ try {
+ return new LinkedHashSet<>(JsonMapper.shared().readValue(latest, new TypeReference>() { }));
+ } catch (JacksonException e) {
+ log.warn("채점 요청에 보낸 문항 목록을 읽지 못해 기업 관점 코멘트의 근거를 인정하지 않습니다. interviewId={}",
+ feedback.getInterviewId());
+ return Set.of();
+ }
+ }
+
/** 이 면접에 실제로 앉아 있던 면접관. 1:1은 명단이 없어 비어 있다. */
private Set interviewMembers(Long interviewId) {
return interviewPersonaRepository.findAllByInterviewIdOrderByPersonaOrderAsc(interviewId).stream()
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewController.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewController.java
index 532fb227..6e47b4c8 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewController.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewController.java
@@ -9,6 +9,7 @@
import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewPrepareResponse;
import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse;
import repit.repit_api_server.domain.userdata.interview.service.ChatInterviewResultService;
+import repit.repit_api_server.domain.userdata.interview.service.InterviewCreateService;
import repit.repit_api_server.domain.userdata.interview.service.InterviewService;
import repit.repit_api_server.domain.userdata.answer.service.AnswerService;
import repit.repit_api_server.global.auth.AuthUser;
@@ -21,14 +22,21 @@
@RequiredArgsConstructor
public class InterviewController {
private final InterviewService interviewService;
+ private final InterviewCreateService interviewCreateService;
private final ChatInterviewResultService chatInterviewResultService;
private final AnswerService answerService;
+ /**
+ * 최종 면접 생성. 기업을 골랐으면 companyId를 싣는다. 준비는 만들지 않는다 — 웹이 이어서 면접 시작을 부른다.
+ *
+ * 선택 헤더 Idempotency-Key(UUID)를 보내면 같은 키의 재전송에 처음 만든 면접을 돌려준다.
+ */
@PostMapping("/create")
public ApiResponse createInterview(
@AuthenticationPrincipal AuthUser authUser,
+ @RequestHeader(value = InterviewCreateService.IDEMPOTENCY_KEY_HEADER, required = false) String idempotencyKey,
@RequestBody CreateInterviewRequest request) {
- return ApiResponse.created(interviewService.createInterview(authUser.id(), request));
+ return ApiResponse.created(interviewCreateService.create(authUser.id(), idempotencyKey, request));
}
// 면접 시작. 질문 재작성을 접수만 하고, 준비가 끝나면 채팅 서버로 면접 데이터가 넘어간다.
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java
index cd2889bd..b025833e 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java
@@ -18,7 +18,8 @@
*
* N:1 면접은 면접관이 여럿이라 {@code personaIds}로 보낸다. 이 값이 있으면 위 두 값은 보지 않는다.
*
- *
{@code companyId}는 선택이다. 고르면 그 기업 정보를 면접에 스냅샷으로 남긴다. 1:1·N:1 모두 받는다.
+ *
{@code companyId}는 선택이다. 고르면 그 기업 정보를 면접에 스냅샷으로 남긴다. 1:1·N:1 모두 받는다. 기업 정보 자체
+ * (facts·요약·표시명·검수 상태)는 받지 않는다 — 서버가 companyId로 다시 읽어 만든다. 본문에 실려 와도 버린다.
*/
@Getter
@Setter
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewPrepareResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewPrepareResponse.java
index c192c37d..bb9626b1 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewPrepareResponse.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewPrepareResponse.java
@@ -4,6 +4,8 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
+import repit.repit_api_server.domain.company.dto.CompanyBadge;
+import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity;
import repit.repit_api_server.domain.userdata.question.entity.QuestionTailorEntity;
import repit.repit_api_server.domain.userdata.question.entity.enums.TailorStatus;
import repit.repit_api_server.domain.userdata.question.preparation.FailureStage;
@@ -38,12 +40,15 @@ public class InterviewPrepareResponse {
// 채팅 서버까지 데이터가 넘어갔는지. PREPARING 동안은 false다.
private boolean chatDelivered;
private String message;
+ // 면접을 만들 때 고른 기업. 고르지 않았으면 비어 있다. 면접 스냅샷에서 읽는다.
+ private CompanyBadge company;
- public static InterviewPrepareResponse of(QuestionTailorEntity tailor, String sessionId) {
+ public static InterviewPrepareResponse of(QuestionTailorEntity tailor, InterviewEntity interview) {
PreparationState state = PreparationState.of(tailor);
return InterviewPrepareResponse.builder()
.interviewId(tailor.getInterviewId())
- .sessionId(sessionId)
+ .sessionId(interview.getSessionId())
+ .company(CompanyBadge.of(interview.getCompanyId(), interview.getCompanySnapshot()))
.jobId(tailor.getJobId())
.analysisJobId(tailor.getAnalysisJobId())
.preparationStatus(state.status())
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewCreateRequestEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewCreateRequestEntity.java
new file mode 100644
index 00000000..3b489a19
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewCreateRequestEntity.java
@@ -0,0 +1,43 @@
+package repit.repit_api_server.domain.userdata.interview.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.CreationTimestamp;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * 면접 생성 요청 키. 같은 사용자가 같은 {@code Idempotency-Key}로 다시 보내면 여기 적힌 면접을 돌려준다.
+ *
+ *
면접과 한 트랜잭션에서 저장한다. 면접 없이 남는 키는 없고, 면접을 지우면 함께 지워진다(ON DELETE CASCADE).
+ */
+@Entity
+@Table(name = "interview_create_request")
+@Getter
+@Builder
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+public class InterviewCreateRequestEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "interview_create_request_id")
+ private Long interviewCreateRequestId;
+
+ @Column(nullable = false)
+ private Long userId;
+
+ @Column(nullable = false)
+ private UUID idempotencyKey;
+
+ // 정규화한 생성 설정의 SHA-256. 같은 키에 다른 설정이 오면 409로 막는 기준이다.
+ @Column(nullable = false, length = 64)
+ private String requestHash;
+
+ @Column(nullable = false)
+ private Long interviewId;
+
+ @CreationTimestamp
+ @Column(nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewCreateRequestRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewCreateRequestRepository.java
new file mode 100644
index 00000000..6f03d6ee
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewCreateRequestRepository.java
@@ -0,0 +1,12 @@
+package repit.repit_api_server.domain.userdata.interview.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import repit.repit_api_server.domain.userdata.interview.entity.InterviewCreateRequestEntity;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface InterviewCreateRequestRepository extends JpaRepository {
+
+ Optional findByUserIdAndIdempotencyKey(Long userId, UUID idempotencyKey);
+}
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 6bae8a57..e4b40272 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
@@ -4,6 +4,7 @@
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import org.springframework.transaction.annotation.Transactional;
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.question.entity.QuestionTailorEntity;
@@ -34,4 +35,26 @@ public interface InterviewRepository extends JpaRepository엔티티에 매핑하지 않고 이 쿼리로만 쓰고 읽는다. 매핑하면 같은 면접을 다른 곳에서 엔티티째 저장할 때 읽어 둔 옛 값으로
+ * 덮어쓴다.
+ */
+ @Transactional
+ @Modifying
+ @Query(value = """
+ update interview
+ set last_feedback_question_ids = cast(:questionIds as jsonb)
+ where interview_id = :interviewId
+ """, nativeQuery = true)
+ int recordLastFeedbackQuestionIds(@Param("interviewId") Long interviewId,
+ @Param("questionIds") String questionIdsJson);
+
+ // 위에서 남긴 목록의 JSON 원문. 남긴 적이 없으면 비어 있다.
+ @Query(value = "select cast(last_feedback_question_ids as text) from interview where interview_id = :interviewId",
+ nativeQuery = true)
+ Optional findLastFeedbackQuestionIds(@Param("interviewId") Long interviewId);
}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateFingerprint.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateFingerprint.java
new file mode 100644
index 00000000..2a41b3ab
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateFingerprint.java
@@ -0,0 +1,49 @@
+package repit.repit_api_server.domain.userdata.interview.service;
+
+import repit.repit_api_server.domain.userdata.interview.dto.request.CreateInterviewRequest;
+import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode;
+import tools.jackson.databind.json.JsonMapper;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * 면접 생성 설정의 지문. 같은 Idempotency-Key로 온 요청이 처음과 같은 설정인지 가린다.
+ *
+ * 생성이 실제로 쓰는 값만, 생성과 같은 규칙으로 정규화해 담는다 — personaIds가 있으면 N:1이고 나머지 면접관 값은 보지 않는다.
+ * 1:1은 personaId가 있으면 그것을, 없으면 personaName을 쓴다. N:1 면접관 순서는 진행 순서를 정하므로 그대로 둔다.
+ * 서버가 만드는 값(스냅샷·시각·새 id)은 넣지 않는다. 키 이름 순으로 직렬화해 필드 순서가 결과를 바꾸지 않는다.
+ */
+final class InterviewCreateFingerprint {
+
+ private InterviewCreateFingerprint() {
+ }
+
+ static String of(CreateInterviewRequest request) {
+ boolean multi = request.getPersonaIds() != null && !request.getPersonaIds().isEmpty();
+ Map canonical = new TreeMap<>();
+ canonical.put("mode", multi ? InterviewMode.MULTI.name() : InterviewMode.SOLO.name());
+ if (multi) {
+ canonical.put("personaIds", request.getPersonaIds());
+ } else if (request.getPersonaId() != null) {
+ canonical.put("personaId", request.getPersonaId());
+ } else {
+ canonical.put("personaName", request.getPersonaName());
+ }
+ canonical.put("companyId", request.getCompanyId());
+ return sha256(JsonMapper.shared().writeValueAsString(canonical));
+ }
+
+ private static String sha256(String value) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(digest);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateService.java
new file mode 100644
index 00000000..15ae4e03
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateService.java
@@ -0,0 +1,117 @@
+package repit.repit_api_server.domain.userdata.interview.service;
+
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionTemplate;
+import repit.repit_api_server.domain.userdata.interview.dto.request.CreateInterviewRequest;
+import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse;
+import repit.repit_api_server.domain.userdata.interview.entity.InterviewCreateRequestEntity;
+import repit.repit_api_server.domain.userdata.interview.repository.InterviewCreateRequestRepository;
+import repit.repit_api_server.global.error.ErrorCodes;
+import repit.repit_api_server.global.exception.BusinessException;
+
+import java.util.Locale;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+/**
+ * 최종 면접 생성(POST /api/interviews/create). 웹이 기업 선택 화면의 시작 버튼에서 한 번 부른다.
+ *
+ * 면접·면접관·요청 키를 한 트랜잭션에 저장한다. 검증이 하나라도 실패하면 모두 롤백돼 면접 없는 키나 키 없는 면접이
+ * 남지 않는다. 이 트랜잭션은 외부 서버를 부르지 않는다 — 질문 준비는 기존대로 웹이 생성 뒤 따로 시작한다
+ * (POST /api/interviews/{id}). 그래서 같은 키를 다시 보내도 준비 작업이 새로 생기지 않는다.
+ *
+ *
{@code Idempotency-Key}는 선택이다. 없으면 예전처럼 요청마다 새 면접을 만든다. 있으면 (사용자, 키)마다 면접 하나다.
+ *
+ * - 같은 키·같은 설정: 처음 만든 면접을 그대로 돌려준다. 그 사이 기업이 숨겨졌어도 다시 검증하지 않는다.
+ * - 같은 키·다른 설정: 409 IDEMPOTENCY_KEY_REUSED.
+ * - 같은 키가 동시에 오면 유일 제약이 하나만 남긴다. 진 쪽은 롤백된 뒤 트랜잭션 밖에서 다시 읽어 위 규칙으로 답한다.
+ *
+ * 키 저장소를 읽지 못하면 그대로 실패한다. 키 보장을 건너뛰고 새 면접을 만들지 않는다.
+ */
+@Service
+@RequiredArgsConstructor
+public class InterviewCreateService {
+
+ private static final Logger log = LoggerFactory.getLogger(InterviewCreateService.class);
+
+ public static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
+ // 웹이 JavaScript number로 다루는 id라 안전한 정수 범위를 넘으면 같은 값으로 되돌아오지 않는다.
+ static final long MAX_SAFE_INTEGER = 9_007_199_254_740_991L;
+ private static final Pattern UUID_FORMAT =
+ Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
+
+ private final InterviewService interviewService;
+ private final InterviewCreateRequestRepository interviewCreateRequestRepository;
+ private final TransactionTemplate transactionTemplate;
+
+ public InterviewResponse create(Long userId, String idempotencyKey, CreateInterviewRequest request) {
+ UUID key = parseKey(idempotencyKey);
+ verifyCompanyId(request.getCompanyId());
+ if (key == null) {
+ return transactionTemplate.execute(status -> interviewService.createInterview(userId, request));
+ }
+
+ String hash = InterviewCreateFingerprint.of(request);
+ Optional existing =
+ interviewCreateRequestRepository.findByUserIdAndIdempotencyKey(userId, key);
+ if (existing.isPresent()) {
+ return replay(userId, existing.get(), hash);
+ }
+
+ try {
+ return transactionTemplate.execute(status -> {
+ InterviewResponse created = interviewService.createInterview(userId, request);
+ interviewCreateRequestRepository.saveAndFlush(InterviewCreateRequestEntity.builder()
+ .userId(userId)
+ .idempotencyKey(key)
+ .requestHash(hash)
+ .interviewId(created.getInterviewId())
+ .build());
+ return created;
+ });
+ } catch (DataIntegrityViolationException e) {
+ // 같은 키의 다른 요청이 먼저 커밋했다. 이 트랜잭션은 이미 롤백됐으니 밖에서 새로 읽는다.
+ InterviewCreateRequestEntity raced = interviewCreateRequestRepository
+ .findByUserIdAndIdempotencyKey(userId, key)
+ .orElseThrow(() -> e);
+ log.info("같은 요청 키로 먼저 만든 면접을 돌려줍니다. userId={}, interviewId={}", userId, raced.getInterviewId());
+ return replay(userId, raced, hash);
+ }
+ }
+
+ private InterviewResponse replay(Long userId, InterviewCreateRequestEntity existing, String hash) {
+ if (!existing.getRequestHash().equals(hash)) {
+ throw new BusinessException("이 요청 키는 다른 면접 설정에 이미 쓰였습니다. 새로 시작하려면 새 요청 키를 보내주세요.",
+ HttpStatus.CONFLICT, ErrorCodes.IDEMPOTENCY_KEY_REUSED);
+ }
+ return interviewService.getInterviewById(userId, existing.getInterviewId());
+ }
+
+ /** 헤더가 없으면 null. 있으면 하이픈이 들어간 UUID여야 한다. 빈 값도 형식 오류다. */
+ static UUID parseKey(String value) {
+ if (value == null) {
+ return null;
+ }
+ if (!UUID_FORMAT.matcher(value.strip()).matches()) {
+ throw invalid(IDEMPOTENCY_KEY_HEADER + " 헤더는 UUID여야 합니다.");
+ }
+ return UUID.fromString(value.strip().toLowerCase(Locale.ROOT));
+ }
+
+ // 고르지 않았으면 키를 생략한다. 보냈다면 서버가 부여한 양의 id여야 한다.
+ static void verifyCompanyId(Long companyId) {
+ if (companyId != null && (companyId < 1 || companyId > MAX_SAFE_INTEGER)) {
+ throw invalid("companyId는 1 이상 " + MAX_SAFE_INTEGER + " 이하의 정수여야 합니다.");
+ }
+ }
+
+ private static BusinessException invalid(String message) {
+ return new BusinessException(message, HttpStatus.UNPROCESSABLE_CONTENT, ErrorCodes.INVALID_INTERVIEW_REQUEST);
+ }
+}
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 2a5c67e1..241ec7af 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
@@ -71,6 +71,10 @@ public class InterviewService {
private final AnalysisLaunchService analysisLaunchService;
private final CompanySnapshotFactory companySnapshotFactory;
+ /**
+ * 면접을 만든다. {@link InterviewCreateService}가 트랜잭션을 열고 부른다 — N:1 면접관 명단·기업 스냅샷·요청 키가
+ * 면접과 함께 저장되거나 함께 롤백된다.
+ */
public InterviewResponse createInterview(Long userId, CreateInterviewRequest request) {
if (request.getPersonaIds() != null && !request.getPersonaIds().isEmpty()) {
return createMultiInterview(userId, request.getPersonaIds(), request.getCompanyId());
@@ -137,7 +141,10 @@ private InterviewResponse createMultiInterview(Long userId, List requested
return InterviewResponse.from(saved, ordered.stream().map(PersonaEntity::getPersonaId).toList());
}
- /** 고른 기업을 지금 모습 그대로 복사해 둔다. 공개 기업이 아니면 404다. 고르지 않았으면 비어 있다. */
+ /**
+ * 고른 기업을 지금 모습 그대로 복사해 둔다. 고르지 않았으면 비어 있다. 공개 기업이 아니면 404 COMPANY_UNAVAILABLE,
+ * 스냅샷을 만들 수 없는 데이터면 503 COMPANY_CONTEXT_UNAVAILABLE이다 — 기업 없는 면접으로 바꿔 만들지 않는다.
+ */
private CompanySnapshot companySnapshotOf(Long companyId) {
return companyId == null ? null : companySnapshotFactory.create(companyId);
}
@@ -221,7 +228,7 @@ public InterviewPrepareResponse prepareInterview(AuthUser authUser, Long intervi
analysisLaunchService.launchIfMissing(authUser);
// N:1은 질문 재작성이 아니라 신규 생성이 섞인 multi tailor로 간다. 갈림길은 서비스 안에 있다.
QuestionTailorEntity tailor = questionTailorService.requestTailor(interview, authUser.user());
- return InterviewPrepareResponse.of(tailor, interview.getSessionId());
+ return InterviewPrepareResponse.of(tailor, interview);
}
/**
@@ -241,7 +248,7 @@ public InterviewPrepareResponse retryPreparation(AuthUser authUser, Long intervi
// 처음 시작할 때 종합 데이터를 요청하지 못했거나, 그 사이 분석 서버가 지금 것을 거부했으면 여기서 요청한다.
analysisLaunchService.launchIfMissing(authUser);
QuestionTailorEntity tailor = questionTailorService.retryPreparation(interview, authUser.user());
- return InterviewPrepareResponse.of(tailor, interview.getSessionId());
+ return InterviewPrepareResponse.of(tailor, interview);
}
/**
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/dto/response/QuestionTailorResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/question/dto/response/QuestionTailorResponse.java
index 785e8455..217b73f8 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/question/dto/response/QuestionTailorResponse.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/question/dto/response/QuestionTailorResponse.java
@@ -4,6 +4,7 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
+import repit.repit_api_server.domain.company.dto.CompanyBadge;
import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode;
import repit.repit_api_server.domain.userdata.question.entity.QuestionTailorEntity;
import repit.repit_api_server.domain.userdata.question.entity.enums.TailorStatus;
@@ -52,6 +53,14 @@ public class QuestionTailorResponse {
private String errorMessage;
+ // 면접을 만들 때 고른 기업. 고르지 않았으면 비어 있다. 면접 스냅샷에서 읽으며, 질문이 모두 기업에 맞게 바뀌었다는 뜻은 아니다.
+ private CompanyBadge company;
+
+ public QuestionTailorResponse withCompany(CompanyBadge company) {
+ this.company = company;
+ return this;
+ }
+
public static QuestionTailorResponse of(QuestionTailorEntity tailor) {
PreparationState state = PreparationState.of(tailor);
diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java
index 0db306d1..e944b379 100644
--- a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java
+++ b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java
@@ -8,6 +8,7 @@
import org.springframework.stereotype.Service;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.transaction.annotation.Transactional;
+import repit.repit_api_server.domain.company.dto.CompanyBadge;
import repit.repit_api_server.domain.metadata.dto.response.ProjectSummaryResponse;
import repit.repit_api_server.domain.userdata.interview.MultiInterviewPanel;
import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity;
@@ -1115,14 +1116,18 @@ public QuestionTailorResponse getTailorResult(Long userId, Long interviewId) {
.orElse(null);
if (tailor == null) {
// 세트는 면접을 시작할 때 꺼낸다. 그 전에는 어느 세트가 될지 정해지지 않았다.
- return QuestionTailorResponse.notRequested(interviewId, List.of());
+ return QuestionTailorResponse.notRequested(interviewId, List.of()).withCompany(companyBadgeOf(interview));
}
// 폴링하는 클라이언트가 PENDING에 갇히지 않도록 조회 시점에도 판정하고, 밀린 뒷단을 마저 밟는다.
expireIfTimedOut(tailor);
completePreparation(tailor);
- return QuestionTailorResponse.of(tailor);
+ return QuestionTailorResponse.of(tailor).withCompany(companyBadgeOf(interview));
+ }
+
+ private static CompanyBadge companyBadgeOf(InterviewEntity interview) {
+ return CompanyBadge.of(interview.getCompanyId(), interview.getCompanySnapshot());
}
// 면접 질문은 본인만 볼 수 있어야 한다.
diff --git a/src/main/java/repit/repit_api_server/global/error/ErrorCodes.java b/src/main/java/repit/repit_api_server/global/error/ErrorCodes.java
new file mode 100644
index 00000000..1338304d
--- /dev/null
+++ b/src/main/java/repit/repit_api_server/global/error/ErrorCodes.java
@@ -0,0 +1,21 @@
+package repit.repit_api_server.global.error;
+
+/**
+ * 오류 응답의 {@code code} 값. 상태 코드만으로는 웹이 안내를 가를 수 없는 경우에만 쓴다(기업 데이터셋 00 §8).
+ *
+ * 외부 입력 형식 오류와 내부 데이터 오류를 같은 값으로 묶지 않는다. 사용자가 할 수 있는 일이 다르다.
+ */
+public final class ErrorCodes {
+
+ // 고른 기업이 없거나 숨겨졌다. 설정 초안을 두고 기업을 다시 고르게 한다.
+ public static final String COMPANY_UNAVAILABLE = "COMPANY_UNAVAILABLE";
+ // 공개 기업인데 저장된 데이터로 실행 스냅샷을 만들 수 없다. 데이터를 고칠 일이라 사용자가 고칠 수 없다.
+ public static final String COMPANY_CONTEXT_UNAVAILABLE = "COMPANY_CONTEXT_UNAVAILABLE";
+ // 같은 Idempotency-Key로 다른 설정의 면접을 만들려 했다.
+ public static final String IDEMPOTENCY_KEY_REUSED = "IDEMPOTENCY_KEY_REUSED";
+ // 면접 생성 요청의 헤더·ID 형식이 틀렸다.
+ public static final String INVALID_INTERVIEW_REQUEST = "INVALID_INTERVIEW_REQUEST";
+
+ private ErrorCodes() {
+ }
+}
diff --git a/src/main/java/repit/repit_api_server/global/error/ErrorResponse.java b/src/main/java/repit/repit_api_server/global/error/ErrorResponse.java
index 7c6b6691..f50af1cc 100644
--- a/src/main/java/repit/repit_api_server/global/error/ErrorResponse.java
+++ b/src/main/java/repit/repit_api_server/global/error/ErrorResponse.java
@@ -1,11 +1,22 @@
package repit.repit_api_server.global.error;
+import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Getter;
-import lombok.RequiredArgsConstructor;
@Getter
-@RequiredArgsConstructor
public class ErrorResponse {
private final String message;
+ // 웹이 같은 상태 코드 안에서 원인을 가를 때 쓰는 선택 값. 없으면 키 자체를 내보내지 않아 기존 응답은 그대로다.
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private final String code;
+
+ public ErrorResponse(String message) {
+ this(message, null);
+ }
+
+ public ErrorResponse(String message, String code) {
+ this.message = message;
+ this.code = code;
+ }
}
diff --git a/src/main/java/repit/repit_api_server/global/error/GlobalExceptionHandler.java b/src/main/java/repit/repit_api_server/global/error/GlobalExceptionHandler.java
index 4a17387a..e2c5ce45 100644
--- a/src/main/java/repit/repit_api_server/global/error/GlobalExceptionHandler.java
+++ b/src/main/java/repit/repit_api_server/global/error/GlobalExceptionHandler.java
@@ -25,8 +25,9 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity handleBusinessException(BusinessException e) {
// 상태 코드만으로는 어느 규칙에 걸렸는지 알 수 없어 한 줄 남긴다. 예상된 흐름이라 스택트레이스는 붙이지 않는다.
- log.warn("업무 규칙에 걸려 요청을 중단했습니다. status={}, message={}", e.getStatus(), e.getMessage());
- return asJson(e.getStatus()).body(new ErrorResponse(e.getMessage()));
+ log.warn("업무 규칙에 걸려 요청을 중단했습니다. status={}, code={}, message={}",
+ e.getStatus(), e.getCode(), e.getMessage());
+ return asJson(e.getStatus()).body(new ErrorResponse(e.getMessage(), e.getCode()));
}
// 직무·자료 저장 규칙 위반. Web이 행·영역마다 문구를 띄우도록 사유 목록을 그대로 내보낸다.
diff --git a/src/main/java/repit/repit_api_server/global/exception/BusinessException.java b/src/main/java/repit/repit_api_server/global/exception/BusinessException.java
index 536a99db..4c70bff7 100644
--- a/src/main/java/repit/repit_api_server/global/exception/BusinessException.java
+++ b/src/main/java/repit/repit_api_server/global/exception/BusinessException.java
@@ -6,15 +6,23 @@
/**
* 클라이언트에게 사유를 그대로 보여줘도 되는 업무 예외.
* 범용 예외 핸들러의 500 응답과 달리 의도한 상태코드와 메시지가 그대로 나간다.
+ *
+ * {@code code}는 선택이다. 같은 상태 코드 안에서 웹이 다르게 안내해야 하는 경우에만 붙인다.
*/
@Getter
public class BusinessException extends RuntimeException {
private final HttpStatus status;
+ private final String code;
public BusinessException(String message, HttpStatus status) {
+ this(message, status, null);
+ }
+
+ public BusinessException(String message, HttpStatus status, String code) {
super(message);
this.status = status;
+ this.code = code;
}
public static BusinessException notFound(String message) {
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 2f312905..2d5d6e43 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -154,7 +154,14 @@ app:
# 나가지 않는다. 분석·채팅 서버는 모르는 필드를 조용히 버리므로, 두 서버가 company를 받도록 배포된 뒤에 채운다.
# 개발·스테이징: classpath:company-data/sample/*.json
# 운영: 실제 데이터가 준비되면 classpath:company-data/prod/*.json
+ # 파일이 빠졌다고 기업을 숨기지 않는다. 내릴 기업은 아래 hidden-codes에 넣는다.
data-location: ${COMPANY_DATA_LOCATION:}
+ # 출처가 모두 SAMPLE인 개발용 패키지를 가져오고 목록·면접 생성에 내보낼지. 개발·스테이징만 true로 켠다.
+ # 운영은 기본값(false) 그대로 둔다 — 위치를 샘플 폴더로 잘못 적어도 샘플이 공개되지 않는다.
+ allow-sample: ${COMPANY_ALLOW_SAMPLE:false}
+ # 목록·신규 선택에서 내릴 기업의 code. 쉼표로 나눈다. 기동할 때 HIDDEN으로 바꾸고, 이미 만든 면접의 스냅샷은 그대로 둔다.
+ # 목록에서 빼면 다음 기동 때 다시 공개한다. 법적 사유의 긴급 삭제는 이 설정으로 대신하지 않는다.
+ hidden-codes: ${COMPANY_HIDDEN_CODES:}
question-pool:
# 이 시간이 지나도록 콜백이 오지 않은 사이클 생성 요청은 잃어버린 것으로 보고, 면접을 시작할 때 다시 요청한다.
generating-timeout: 5m
diff --git a/src/main/resources/db/migration/V35__company_dataset_contract.sql b/src/main/resources/db/migration/V35__company_dataset_contract.sql
new file mode 100644
index 00000000..c7c066de
--- /dev/null
+++ b/src/main/resources/db/migration/V35__company_dataset_contract.sql
@@ -0,0 +1,28 @@
+-- 기업 데이터셋 적용(00 공통 계약, 02 API).
+
+-- 출처가 모두 SAMPLE인 개발용 패키지. 운영에서는 가져오지도, 목록·면접 생성에 내보내지도 않는다(app.company.allow-sample).
+ALTER TABLE company ADD COLUMN sample BOOLEAN NOT NULL DEFAULT false;
+UPDATE company c
+ SET sample = NOT EXISTS (SELECT 1
+ FROM company_source s
+ WHERE s.company_id = c.company_id
+ AND s.source_type <> 'SAMPLE');
+
+-- fact는 자기 기업의 출처만 가리킨다. 출처 id만 걸면 다른 기업의 출처를 가리켜도 DB가 받아 준다.
+ALTER TABLE company_source ADD CONSTRAINT uq_company_source_company_source UNIQUE (company_id, source_id);
+ALTER TABLE company_fact ADD CONSTRAINT fk_company_fact_own_source
+ FOREIGN KEY (company_id, source_id) REFERENCES company_source (company_id, source_id);
+
+-- 면접 생성 중복 방지. 같은 사용자가 같은 키로 다시 보내면 처음 만든 면접을 돌려준다.
+-- 키 등록과 면접 저장은 한 트랜잭션이라 면접 없는 키는 남지 않는다. 면접을 지우면 키도 함께 지운다.
+CREATE TABLE interview_create_request (
+ interview_create_request_id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ idempotency_key UUID NOT NULL,
+ -- 정규화한 생성 설정(mode·면접관·companyId)의 SHA-256. 같은 키에 다른 설정이 오면 409다.
+ request_hash VARCHAR(64) NOT NULL,
+ interview_id BIGINT NOT NULL REFERENCES interview (interview_id) ON DELETE CASCADE,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ CONSTRAINT uq_interview_create_request_user_key UNIQUE (user_id, idempotency_key)
+);
+CREATE INDEX idx_interview_create_request_interview ON interview_create_request (interview_id);
diff --git a/src/main/resources/db/migration/V36__feedback_requested_question_ids.sql b/src/main/resources/db/migration/V36__feedback_requested_question_ids.sql
new file mode 100644
index 00000000..44a764fe
--- /dev/null
+++ b/src/main/resources/db/migration/V36__feedback_requested_question_ids.sql
@@ -0,0 +1,8 @@
+-- 기업 관점 코멘트(companyFit)가 근거로 댈 수 있는 문항을 채점 요청 시점의 것으로 고정한다(00 §6).
+-- 콜백 때 면접 기록에서 다시 뽑으면, 요청 뒤 답변 API로 더해진 답변의 문항까지 근거로 통과한다.
+
+-- 이 채점 작업에 실어 보낸 문항 가운데 답변이 있는 것의 id. 접수 응답을 받아 행을 만들 때 넣는다.
+ALTER TABLE feedback ADD COLUMN requested_question_ids JSONB;
+
+-- 가장 최근 채점 요청에 실은 같은 목록. 보내기 직전에 남긴다. 콜백이 접수 응답보다 먼저 와 작업 행에 목록이 없을 때 쓴다.
+ALTER TABLE interview ADD COLUMN last_feedback_question_ids JSONB;
diff --git a/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterIsolationTest.java b/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterIsolationTest.java
index 8a155642..24d0fb92 100644
--- a/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterIsolationTest.java
+++ b/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterIsolationTest.java
@@ -19,10 +19,12 @@
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Consumer;
+import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@@ -30,8 +32,8 @@
* 가져오기를 실제 트랜잭션 경계 그대로 돌린다(04 §3-4 "기업 하나 = 트랜잭션 하나").
*
*
테스트 트랜잭션으로 감싸면 가져오기가 그 트랜잭션에 합류해, 한 기업의 DB 오류가 트랜잭션 전체를 망가뜨린다. 그래서
- * 감싸지 않는다. 가져오기는 이번 파일에 없는 공개 기업을 숨기므로, 시작할 때 공개였던 기업은 끝나고 다시 공개로 돌리고
- * 이 테스트가 만든 기업은 지운다.
+ * 감싸지 않는다. 숨김은 이 테스트가 넘긴 code에만 하지만, 혹시 다른 공개 기업이 바뀌어도 남지 않도록 시작할 때 공개였던
+ * 기업은 끝나고 다시 공개로 돌리고 이 테스트가 만든 기업은 지운다.
*/
@SpringBootTest
class CompanyDataImporterIsolationTest {
@@ -100,6 +102,11 @@ private Result importAll() throws IOException {
return importer.importFrom("file:" + dir.toAbsolutePath() + "/*.json");
}
+ private Result importAllHiding(String... names) throws IOException {
+ return importer.importFrom("file:" + dir.toAbsolutePath() + "/*.json",
+ Arrays.stream(names).map(this::code).collect(Collectors.toSet()));
+ }
+
private CompanyEntity company(String name) {
return companyRepository.findByCode(code(name)).orElseThrow();
}
@@ -121,7 +128,7 @@ private CompanyEntity company(String name) {
assertThat(company("b").getDartCorpCode()).isNull();
}
- /** 같은 법인을 다른 code로 다시 냈다. 숨긴 옛 기업이 식별자를 놓아 새 기업이 들어간다. */
+ /** 같은 법인을 다른 code로 다시 냈다. 설정으로 숨긴 옛 기업이 식별자를 놓아 새 기업이 들어간다. */
@Test
void 숨긴_기업이_들고_있던_식별자는_새_기업이_받는다() throws IOException {
writePackage("old", dartCorpCode);
@@ -129,7 +136,7 @@ private CompanyEntity company(String name) {
Files.delete(dir.resolve(code("old") + ".json"));
writePackage("new", dartCorpCode);
- Result result = importAll();
+ Result result = importAllHiding("old");
assertThat(result.created()).isEqualTo(1);
assertThat(result.hidden()).isEqualTo(1);
diff --git a/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterTest.java b/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterTest.java
index 31a3e59a..360b2ae5 100644
--- a/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterTest.java
+++ b/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterTest.java
@@ -9,6 +9,8 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.ClassPathResource;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionTemplate;
+import repit.repit_api_server.domain.company.dto.CompanySummaryResponse;
import repit.repit_api_server.domain.company.entity.CompanyEntity;
import repit.repit_api_server.domain.company.entity.CompanyFactEntity;
import repit.repit_api_server.domain.company.entity.CompanySourceEntity;
@@ -19,13 +21,17 @@
import repit.repit_api_server.domain.company.repository.CompanyRepository;
import repit.repit_api_server.domain.company.repository.CompanySourceRepository;
import repit.repit_api_server.domain.company.service.CompanyQueryService;
+import repit.repit_api_server.domain.company.service.CompanyVisibility;
+import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
+import java.util.Comparator;
import java.util.List;
+import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -52,6 +58,10 @@ class CompanyDataImporterTest {
private CompanySourceRepository companySourceRepository;
@Autowired
private CompanyQueryService companyQueryService;
+ @Autowired
+ private TransactionTemplate transactionTemplate;
+ @Autowired
+ private ObjectMapper objectMapper;
@PersistenceContext
private EntityManager entityManager;
@@ -156,17 +166,73 @@ private List factIds(String code) {
.getFirst().getContent()).isEqualTo("여러 이용자가 실시간으로 대전하는 모바일 대전 게임");
}
+ /** 파일이 빠졌다고 숨기거나 지우지 않는다(02 §3-3). 일부 파일만 배포돼도 공개 기업이 사라지지 않는다. */
@Test
- void 파일이_빠진_기업은_숨기고_돌아오면_다시_공개한다() throws IOException {
+ void 파일이_빠진_기업은_숨기지_않고_기존_정보로_둔다() throws IOException {
importer.importFrom(location());
+ List gamesFacts = factIds("sample-games");
Files.delete(dir.resolve("sample-games.json"));
- assertThat(importer.importFrom(location())).isEqualTo(new Result(0, 0, 2, 1, 0));
+ assertThat(importer.importFrom(location())).isEqualTo(new Result(0, 0, 2, 0, 0));
+ assertThat(company("sample-games").getStatus()).isEqualTo(CompanyStatus.PUBLISHED);
+ assertThat(factIds("sample-games")).isEqualTo(gamesFacts);
+ }
+
+ /** 숨김은 설정으로만 한다. 파일이 있어도 반영하지 않고, 설정에서 빼면 다음에 다시 공개한다. */
+ @Test
+ void 설정으로_숨긴_기업은_파일이_있어도_숨기고_빼면_다시_공개한다() throws IOException {
+ importer.importFrom(location());
+ Long gamesId = company("sample-games").getCompanyId();
+
+ assertThat(importer.importFrom(location(), Set.of("sample-games"))).isEqualTo(new Result(0, 0, 2, 1, 0));
assertThat(company("sample-games").getStatus()).isEqualTo(CompanyStatus.HIDDEN);
+ assertThat(companyQueryService.search(null, null)).extracting(CompanySummaryResponse::companyId)
+ .doesNotContain(gamesId);
+ // 이미 숨긴 기업을 다시 숨기지는 않는다.
+ assertThat(importer.importFrom(location(), Set.of("sample-games"))).isEqualTo(new Result(0, 0, 2, 0, 0));
- copy("sample-games.json", dir.resolve("sample-games.json"));
assertThat(importer.importFrom(location())).isEqualTo(new Result(0, 1, 2, 0, 0));
assertThat(company("sample-games").getStatus()).isEqualTo(CompanyStatus.PUBLISHED);
+ assertThat(company("sample-games").getCompanyId()).isEqualTo(gamesId);
+ }
+
+ /** 위치를 비운 환경에서도 내릴 기업은 내린다. */
+ @Test
+ void 위치가_비어도_설정으로_숨긴다() throws IOException {
+ importer.importFrom(location());
+
+ assertThat(importer.importFrom("", Set.of("sample-pay"))).isEqualTo(new Result(0, 0, 0, 1, 0));
+ assertThat(company("sample-pay").getStatus()).isEqualTo(CompanyStatus.HIDDEN);
+ }
+
+ /** 운영처럼 SAMPLE을 허용하지 않는 환경은 샘플 패키지를 가져오지 않고, 이미 들어온 샘플도 목록에 내보내지 않는다. */
+ @Test
+ void SAMPLE을_막은_환경은_샘플을_가져오지도_내보내지도_않는다() throws IOException {
+ CompanyVisibility production = new CompanyVisibility(false);
+ CompanyDataImporter productionImporter = new CompanyDataImporter(companyRepository, companySourceRepository,
+ companyFactRepository, transactionTemplate, objectMapper, production);
+
+ assertThat(productionImporter.importFrom(location(), Set.of())).isEqualTo(new Result(0, 0, 0, 0, 3));
+ assertThat(companyRepository.findAll()).isEmpty();
+
+ // 허용하던 때 들어온 샘플이 남아 있어도 운영 목록에는 나가지 않는다.
+ importer.importFrom(location());
+ assertThat(company("sample-commerce").isSample()).isTrue();
+ assertThat(new CompanyQueryService(companyRepository, companyFactRepository, production).search(null, null))
+ .isEmpty();
+ assertThat(companyQueryService.search(null, null)).hasSize(3);
+ }
+
+ /** 검수하지 않은 실제 기업이 DB에 들어와 있어도 목록에 내보내지 않는다. 가져오기는 검수 기록 없는 실제 기업을 막는다. */
+ @Test
+ void 검수_기록이_없는_실제_기업은_목록에_나가지_않는다() throws IOException {
+ importer.importFrom(location());
+ entityManager.createNativeQuery("update company set sample = false where code = 'sample-pay'").executeUpdate();
+ entityManager.clear();
+
+ assertThat(companyQueryService.search(null, null)).extracting(CompanySummaryResponse::displayName)
+ .doesNotContain("샘플페이")
+ .hasSize(2);
}
@Test
@@ -200,10 +266,11 @@ private List factIds(String code) {
importer.importFrom(location());
Path empty = Files.createDirectory(dir.resolve("empty"));
- assertThat(importer.importFrom("")).isEqualTo(Result.NONE);
- assertThat(importer.importFrom(null)).isEqualTo(Result.NONE);
+ Result none = new Result(0, 0, 0, 0, 0);
+ assertThat(importer.importFrom("")).isEqualTo(none);
+ assertThat(importer.importFrom(null)).isEqualTo(none);
// 위치를 잘못 적었을 수 있다. 숨기지도 않는다.
- assertThat(importer.importFrom("file:" + empty.toAbsolutePath() + "/*.json")).isEqualTo(Result.NONE);
+ assertThat(importer.importFrom("file:" + empty.toAbsolutePath() + "/*.json")).isEqualTo(none);
assertThat(companyRepository.findAllByStatus(CompanyStatus.PUBLISHED)).hasSize(3);
}
@@ -231,4 +298,39 @@ private CompanyEntity parentOf(String code) {
importer.importFrom(location());
assertThat(parentOf("sample-commerce")).isNull();
}
+
+ /** 자기 자신이나 돌고 돌아 자기에게 오는 모회사는 연결하지 않는다. 파일은 반영한다(02 §3-2 규칙 8). */
+ @Test
+ void 자기_참조와_순환_모회사는_연결하지_않고_파일은_반영한다() throws IOException {
+ rewrite("sample-commerce.json", "\"parentCode\": null", "\"parentCode\": \"sample-commerce\"");
+ rewrite("sample-pay.json", "\"parentCode\": null", "\"parentCode\": \"sample-games\"");
+ rewrite("sample-games.json", "\"parentCode\": null", "\"parentCode\": \"sample-pay\"");
+
+ assertThat(importer.importFrom(location())).isEqualTo(new Result(3, 0, 0, 0, 0));
+ assertThat(parentOf("sample-commerce")).isNull();
+ assertThat(parentOf("sample-pay")).isNull();
+ assertThat(parentOf("sample-games")).isNull();
+ }
+
+ /** 고리에 들어가기만 하는 기업은 연결한다. 고리 안의 기업만 끊는다. */
+ @Test
+ void 고리_밖에서_고리로_이어지는_연결은_둔다() throws IOException {
+ rewrite("sample-commerce.json", "\"parentCode\": null", "\"parentCode\": \"sample-pay\"");
+ rewrite("sample-pay.json", "\"parentCode\": null", "\"parentCode\": \"sample-games\"");
+ rewrite("sample-games.json", "\"parentCode\": null", "\"parentCode\": \"sample-pay\"");
+
+ importer.importFrom(location());
+ assertThat(parentOf("sample-commerce").getCode()).isEqualTo("sample-pay");
+ assertThat(parentOf("sample-pay")).isNull();
+ assertThat(parentOf("sample-games")).isNull();
+ }
+
+ @Test
+ void 샘플_패키지는_SAMPLE로_표시하고_정렬은_분류_이름_id_순이다() throws IOException {
+ importer.importFrom(location());
+
+ assertThat(companyRepository.findAll()).allMatch(CompanyEntity::isSample);
+ assertThat(companyQueryService.search(null, null)).extracting(CompanySummaryResponse::category)
+ .isSortedAccordingTo(Comparator.naturalOrder());
+ }
}
diff --git a/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataValidatorTest.java b/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataValidatorTest.java
index b069ae45..8a761661 100644
--- a/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataValidatorTest.java
+++ b/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataValidatorTest.java
@@ -177,9 +177,28 @@ private static void removeFacts(ObjectNode node, String kind) {
.put("reviewedBy", "검수자").put("reviewedAt", "2026-10-15")))).isEmpty();
}
+ /** 자기 참조는 파일을 막지 않는다. 가져오기가 관계만 저장하지 않는다(02 §3-2 규칙 8). */
@Test
- void 자기_자신을_모회사로_두면_오류다() {
- assertThat(violate(node -> node.put("parentCode", "sample-commerce"))).anyMatch(e -> e.startsWith("parentCode"));
+ void 자기_자신을_모회사로_둬도_파일_오류는_아니다() {
+ assertThat(violate(node -> node.put("parentCode", "sample-commerce"))).isEmpty();
+ }
+
+ /** 오류 문구는 로그에 남는다. 출처 주소·법인 식별번호 같은 파일 값은 싣지 않는다. */
+ @Test
+ void 오류_문구에_파일_값을_싣지_않는다() {
+ List errors = violate(node -> {
+ ((ObjectNode) node.get("sources").get(0)).put("url", "http://secret.example/path");
+ node.put("dartCorpCode", "12AB");
+ });
+
+ assertThat(errors).isNotEmpty().noneMatch(e -> e.contains("secret.example") || e.contains("12AB"));
+ }
+
+ /** 실행 입력은 앞뒤 공백을 뺀 뒤 센다. */
+ @Test
+ void 길이는_앞뒤_공백을_빼고_센다() {
+ assertThat(violate(node -> fact(node, 0).put("title", " " + "가".repeat(20) + " "))).isEmpty();
+ assertThat(violate(node -> fact(node, 0).put("title", "가".repeat(21)))).anyMatch(e -> e.startsWith("facts[0].title"));
}
@Test
diff --git a/src/test/java/repit/repit_api_server/domain/company/service/CompanyQueryServiceTest.java b/src/test/java/repit/repit_api_server/domain/company/service/CompanyQueryServiceTest.java
index 176d1120..62b3d17c 100644
--- a/src/test/java/repit/repit_api_server/domain/company/service/CompanyQueryServiceTest.java
+++ b/src/test/java/repit/repit_api_server/domain/company/service/CompanyQueryServiceTest.java
@@ -40,7 +40,7 @@ class CompanyQueryServiceTest {
@BeforeEach
void setUp() {
- service = new CompanyQueryService(companyRepository, companyFactRepository);
+ service = new CompanyQueryService(companyRepository, companyFactRepository, new CompanyVisibility(false));
CompanyEntity commerce = company(3L, "샘플커머스", CompanyCategory.CONSUMER, List.of("샘플마켓", "Sample Commerce"));
CompanyEntity games = company(4L, "샘플게임즈", CompanyCategory.GAME, List.of("Sample Games"));
@@ -65,6 +65,8 @@ private CompanyEntity company(Long id, String displayName, CompanyCategory categ
.category(category)
.status(CompanyStatus.PUBLISHED)
.dataAsOf(LocalDate.parse("2026-10-15"))
+ .reviewedBy("검수자")
+ .reviewedAt(LocalDate.parse("2026-10-16"))
.build();
}
@@ -155,4 +157,36 @@ private List names(List companies) {
assertThat(service.search(null, null).getFirst().parentDisplayName()).isNull();
}
+
+ @Test
+ void 이름이_같으면_companyId_순이다() {
+ CompanyEntity later = company(12L, "같은이름", CompanyCategory.SECURITY, List.of());
+ CompanyEntity earlier = company(11L, "같은이름", CompanyCategory.SECURITY, List.of());
+ when(companyRepository.findWithParentByStatus(CompanyStatus.PUBLISHED)).thenReturn(List.of(later, earlier));
+
+ assertThat(service.search(null, null)).extracting(CompanySummaryResponse::companyId).containsExactly(11L, 12L);
+ }
+
+ /** 검수 기록이 없는 실제 기업과, 허용하지 않은 환경의 SAMPLE은 공개 상태여도 내보내지 않는다. */
+ @Test
+ void 검수_조건을_만족하는_기업만_내보낸다() {
+ CompanyEntity unreviewed = company(13L, "미검수", CompanyCategory.SECURITY, List.of());
+ unreviewed.setReviewedAt(null);
+ CompanyEntity sample = company(14L, "샘플보안", CompanyCategory.SECURITY, List.of());
+ sample.setSample(true);
+ sample.setReviewedBy(null);
+ sample.setReviewedAt(null);
+ when(companyRepository.findWithParentByStatus(CompanyStatus.PUBLISHED)).thenReturn(List.of(unreviewed, sample));
+
+ assertThat(service.search(null, null)).isEmpty();
+ CompanyQueryService staging = new CompanyQueryService(companyRepository, companyFactRepository,
+ new CompanyVisibility(true));
+ assertThat(names(staging.search(null, null))).containsExactly("샘플보안");
+ }
+
+ @Test
+ void 검색어의_SQL_패턴_문자는_글자_그대로_찾는다() {
+ assertThat(service.search("%%", null)).isEmpty();
+ assertThat(service.search("__", null)).isEmpty();
+ }
}
diff --git a/src/test/java/repit/repit_api_server/domain/company/service/CompanySnapshotFactoryTest.java b/src/test/java/repit/repit_api_server/domain/company/service/CompanySnapshotFactoryTest.java
index 2dc40a58..dd65c2fa 100644
--- a/src/test/java/repit/repit_api_server/domain/company/service/CompanySnapshotFactoryTest.java
+++ b/src/test/java/repit/repit_api_server/domain/company/service/CompanySnapshotFactoryTest.java
@@ -1,5 +1,6 @@
package repit.repit_api_server.domain.company.service;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -18,10 +19,13 @@
import repit.repit_api_server.domain.company.entity.enums.SignalsOrigin;
import repit.repit_api_server.domain.company.repository.CompanyFactRepository;
import repit.repit_api_server.domain.company.repository.CompanyRepository;
+import repit.repit_api_server.global.error.ErrorCodes;
import repit.repit_api_server.global.exception.BusinessException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
+import java.time.LocalDate;
+import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -43,9 +47,9 @@ class CompanySnapshotFactoryTest {
@BeforeEach
void setUp() {
- factory = new CompanySnapshotFactory(companyRepository, companyFactRepository);
+ factory = new CompanySnapshotFactory(companyRepository, companyFactRepository, new CompanyVisibility(false));
- when(companyRepository.findByCompanyIdAndStatus(3L, CompanyStatus.PUBLISHED)).thenReturn(Optional.of(
+ when(companyRepository.findForShareByCompanyId(3L)).thenReturn(Optional.of(
CompanyEntity.builder()
.companyId(3L)
.code("sample-commerce")
@@ -55,7 +59,10 @@ void setUp() {
// 규모가 있어도 스냅샷에는 싣지 않는다.
.legalSize(LegalSize.LARGE)
.status(CompanyStatus.PUBLISHED)
- .interviewerBrief("샘플커머스는 가상의 기업이다.")
+ .reviewedBy("검수자")
+ .reviewedAt(LocalDate.parse("2026-10-16"))
+ // 앞뒤 공백은 빼고 싣는다.
+ .interviewerBrief(" 샘플커머스는 가상의 기업이다.\n")
.build()));
when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L)).thenReturn(List.of(
fact(21L, CompanyFactKind.BUSINESS, "샘플마켓", 0, List.of("잘못 들어간 신호")),
@@ -112,14 +119,40 @@ private CompanyFactEntity fact(Long factId, CompanyFactKind kind, String title,
assertThat(json.get("facts").get(0).get("positiveSignals").isArray()).isTrue();
}
+ private static void assertError(ThrowingCallable call, HttpStatus status,
+ String code) {
+ assertThatThrownBy(call)
+ .isInstanceOf(BusinessException.class)
+ .satisfies(e -> {
+ assertThat(((BusinessException) e).getStatus()).isEqualTo(status);
+ assertThat(((BusinessException) e).getCode()).isEqualTo(code);
+ });
+ }
+
+ private static void assertContextUnavailable(ThrowingCallable call) {
+ assertError(call, HttpStatus.SERVICE_UNAVAILABLE, ErrorCodes.COMPANY_CONTEXT_UNAVAILABLE);
+ }
+
@Test
void 공개되지_않았거나_없는_기업이면_404다() {
- when(companyRepository.findByCompanyIdAndStatus(4L, CompanyStatus.PUBLISHED)).thenReturn(Optional.empty());
+ when(companyRepository.findForShareByCompanyId(4L)).thenReturn(Optional.empty());
+ assertError(() -> factory.create(4L), HttpStatus.NOT_FOUND, ErrorCodes.COMPANY_UNAVAILABLE);
- assertThatThrownBy(() -> factory.create(4L))
- .isInstanceOf(BusinessException.class)
- .extracting(e -> ((BusinessException) e).getStatus())
- .isEqualTo(HttpStatus.NOT_FOUND);
+ when(companyRepository.findForShareByCompanyId(5L)).thenReturn(Optional.of(CompanyEntity.builder()
+ .companyId(5L).status(CompanyStatus.HIDDEN).reviewedBy("검수자").reviewedAt(LocalDate.now()).build()));
+ assertError(() -> factory.create(5L), HttpStatus.NOT_FOUND, ErrorCodes.COMPANY_UNAVAILABLE);
+ }
+
+ /** 공개 상태여도 검수 기록이 없는 실제 기업, 허용하지 않은 환경의 SAMPLE은 고를 수 없다. */
+ @Test
+ void 검수_조건을_만족하지_않으면_404다() {
+ when(companyRepository.findForShareByCompanyId(6L)).thenReturn(Optional.of(CompanyEntity.builder()
+ .companyId(6L).status(CompanyStatus.PUBLISHED).build()));
+ assertError(() -> factory.create(6L), HttpStatus.NOT_FOUND, ErrorCodes.COMPANY_UNAVAILABLE);
+
+ when(companyRepository.findForShareByCompanyId(7L)).thenReturn(Optional.of(CompanyEntity.builder()
+ .companyId(7L).status(CompanyStatus.PUBLISHED).sample(true).build()));
+ assertError(() -> factory.create(7L), HttpStatus.NOT_FOUND, ErrorCodes.COMPANY_UNAVAILABLE);
}
@Test
@@ -127,7 +160,44 @@ private CompanyFactEntity fact(Long factId, CompanyFactKind kind, String title,
when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L))
.thenReturn(List.of(fact(21L, CompanyFactKind.BUSINESS, "가".repeat(21), 0, List.of())));
- assertThatThrownBy(() -> factory.create(3L)).isInstanceOf(IllegalStateException.class);
+ assertContextUnavailable(() -> factory.create(3L));
+ }
+
+ /** 길이는 앞뒤 공백을 뺀 뒤 code point로 센다. 이모지 하나는 한 글자다. */
+ @Test
+ void 길이는_앞뒤_공백을_빼고_code_point로_센다() {
+ when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L)).thenReturn(List.of(
+ fact(21L, CompanyFactKind.BUSINESS, " " + "😀".repeat(20) + " ", 0, List.of()),
+ fact(23L, CompanyFactKind.TALENT, "오너십", 1, List.of("신".repeat(100)))));
+
+ CompanySnapshot snapshot = factory.create(3L);
+ assertThat(snapshot.facts().getFirst().title()).isEqualTo("😀".repeat(20));
+
+ when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L)).thenReturn(List.of(
+ fact(21L, CompanyFactKind.BUSINESS, "샘플마켓", 0, List.of()),
+ fact(23L, CompanyFactKind.TALENT, "오너십", 1, List.of("신".repeat(101)))));
+ assertContextUnavailable(() -> factory.create(3L));
+ }
+
+ @Test
+ void 신호가_네_개면_만들지_않는다() {
+ when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L)).thenReturn(List.of(
+ fact(21L, CompanyFactKind.BUSINESS, "샘플마켓", 0, List.of()),
+ fact(23L, CompanyFactKind.TALENT, "오너십", 1, List.of("하나", "둘", "셋", "넷"))));
+
+ assertContextUnavailable(() -> factory.create(3L));
+ }
+
+ @Test
+ void 정보가_스무_개를_넘으면_만들지_않는다() {
+ List facts = new ArrayList<>();
+ facts.add(fact(1L, CompanyFactKind.TALENT, "오너십", 0, List.of()));
+ for (long id = 2; id <= 21; id++) {
+ facts.add(fact(id, CompanyFactKind.BUSINESS, "사업" + id, (int) id, List.of()));
+ }
+ when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L)).thenReturn(facts);
+
+ assertContextUnavailable(() -> factory.create(3L));
}
/** 분석 서버는 인재상·주력 사업이 하나도 없는 기업 정보를 422로 거부한다. 고정된 스냅샷이라 면접을 영영 못 연다. */
@@ -135,22 +205,23 @@ private CompanyFactEntity fact(Long factId, CompanyFactKind kind, String title,
void 인재상이나_주력_사업이_없으면_만들지_않는다() {
when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L))
.thenReturn(List.of(fact(21L, CompanyFactKind.BUSINESS, "샘플마켓", 0, List.of())));
- assertThatThrownBy(() -> factory.create(3L)).isInstanceOf(IllegalStateException.class);
+ assertContextUnavailable(() -> factory.create(3L));
when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L))
.thenReturn(List.of(fact(23L, CompanyFactKind.TALENT, "오너십", 0, List.of())));
- assertThatThrownBy(() -> factory.create(3L)).isInstanceOf(IllegalStateException.class);
+ assertContextUnavailable(() -> factory.create(3L));
}
@Test
void 이름이나_요약이_비어_있으면_만들지_않는다() {
- when(companyRepository.findByCompanyIdAndStatus(4L, CompanyStatus.PUBLISHED)).thenReturn(Optional.of(
+ when(companyRepository.findForShareByCompanyId(4L)).thenReturn(Optional.of(
CompanyEntity.builder().companyId(4L).code("blank").displayName("샘플")
- .category(CompanyCategory.CONSUMER).status(CompanyStatus.PUBLISHED).interviewerBrief(" ").build()));
+ .category(CompanyCategory.CONSUMER).status(CompanyStatus.PUBLISHED)
+ .reviewedBy("검수자").reviewedAt(LocalDate.parse("2026-10-16")).interviewerBrief(" ").build()));
when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(4L)).thenReturn(List.of(
fact(21L, CompanyFactKind.BUSINESS, "샘플마켓", 0, List.of()),
fact(23L, CompanyFactKind.TALENT, "오너십", 1, List.of())));
- assertThatThrownBy(() -> factory.create(4L)).isInstanceOf(IllegalStateException.class);
+ assertContextUnavailable(() -> factory.create(4L));
}
}
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReaderTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReaderTest.java
new file mode 100644
index 00000000..aa12e6d6
--- /dev/null
+++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReaderTest.java
@@ -0,0 +1,172 @@
+package repit.repit_api_server.domain.userdata.feedback.service;
+
+import org.junit.jupiter.api.Test;
+import repit.repit_api_server.domain.company.dto.CompanySnapshot;
+import repit.repit_api_server.domain.userdata.feedback.dto.response.CompanyFitResponse;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** 기업 관점 코멘트의 근거 정책(00 §6). 면접 스냅샷과 그 작업의 답변 있는 문항에 맞는 항목만 남는다. */
+class CompanyFitReaderTest {
+
+ private static final CompanySnapshot SNAPSHOT = new CompanySnapshot("12", "샘플커머스", "CONSUMER",
+ List.of(new CompanySnapshot.Fact("101", "BUSINESS", "샘플마켓", "온라인 거래 서비스", List.of(), List.of()),
+ new CompanySnapshot.Fact("102", "TALENT", "고객 관점", "고객 불편을 먼저 본다",
+ List.of("사용자 불편을 근거로 우선순위를 정함"), List.of()),
+ new CompanySnapshot.Fact("103", "TALENT", "오너십", "끝까지 책임진다", List.of(), List.of()),
+ new CompanySnapshot.Fact("104", "PROBLEM", "재고 초과 판매", "동시 주문 재고 문제", List.of(), List.of())),
+ "샘플커머스는 가상의 기업이다.");
+ // 문항 id는 불투명한 문자열이다. 숫자가 아니어도 된다.
+ private static final Set ANSWERED = Set.of("q1", "q2", "7");
+
+ private static CompanyFitResponse read(String json) {
+ return CompanyFitReader.read(JsonMapper.shared().readTree(json), SNAPSHOT, ANSWERED, 1L);
+ }
+
+ private static String item(String factId, String detailField, String detail, String questionIds) {
+ return """
+ { "factId": "%s", "kind": "MISSION_VISION", "focus": "모델이 지은 이름", "%s": "%s", "questionIds": %s }"""
+ .formatted(factId, detailField, detail, questionIds);
+ }
+
+ private static String fit(String comment, String matched, String gaps) {
+ return """
+ { "comment": "%s", "matchedFocus": [%s], "gaps": [%s] }""".formatted(comment, matched, gaps);
+ }
+
+ @Test
+ void 맞는_항목은_kind와_focus를_스냅샷에서_채운다() {
+ CompanyFitResponse fit = read(fit(" 고객 관점이 잘 드러났습니다. ",
+ item("102", "evidence", "사용자 문의를 근거로 우선순위를 정했다고 설명했습니다.", "[\"q2\"]"),
+ item("103", "suggestion", "결정 뒤 결과를 확인한 과정을 덧붙이면 좋습니다.", "[\"q1\"]")));
+
+ assertThat(fit.comment()).isEqualTo("고객 관점이 잘 드러났습니다.");
+ assertThat(fit.matchedFocus()).singleElement().satisfies(item -> {
+ assertThat(item.factId()).isEqualTo("102");
+ assertThat(item.kind()).isEqualTo("TALENT");
+ assertThat(item.focus()).isEqualTo("고객 관점");
+ assertThat(item.questionIds()).containsExactly("q2");
+ });
+ assertThat(fit.gaps()).singleElement().satisfies(gap -> {
+ assertThat(gap.kind()).isEqualTo("TALENT");
+ assertThat(gap.focus()).isEqualTo("오너십");
+ assertThat(gap.suggestion()).startsWith("결정 뒤");
+ });
+ }
+
+ @Test
+ void 스냅샷에_없는_factId는_뺀다() {
+ CompanyFitResponse fit = read(fit("코멘트",
+ item("999", "evidence", "근거", "[\"q1\"]") + "," + item("101", "evidence", "근거", "[\"q1\"]"), ""));
+
+ assertThat(fit.matchedFocus()).extracting(CompanyFitResponse.Item::factId).containsExactly("101");
+ }
+
+ @Test
+ void 답변_있는_문항만_남기고_겹친_id를_뺀다() {
+ CompanyFitResponse fit = read(fit("코멘트",
+ item("101", "evidence", "근거", "[\"q9\", \"q1\", \"q1\", 7, \"7\"]"), ""));
+
+ // 정수로 와도 같은 글자로 받는다. 순서는 받은 대로다.
+ assertThat(fit.matchedFocus().getFirst().questionIds()).containsExactly("q1", "7");
+ }
+
+ @Test
+ void 유효_문항이_없는_항목은_뺀다() {
+ CompanyFitResponse fit = read(fit("코멘트",
+ item("101", "evidence", "근거", "[\"q9\"]") + ","
+ + item("102", "evidence", "근거", "[]") + ","
+ + "{ \"factId\": \"103\", \"evidence\": \"근거\" }" + ","
+ + item("104", "evidence", "근거", "[\"q2\"]"), ""));
+
+ assertThat(fit.matchedFocus()).extracting(CompanyFitResponse.Item::factId).containsExactly("104");
+ }
+
+ @Test
+ void 근거_문장이_비었거나_150자를_넘으면_항목을_뺀다() {
+ CompanyFitResponse fit = read(fit("코멘트",
+ item("101", "evidence", " ", "[\"q1\"]") + ","
+ + item("102", "evidence", "가".repeat(151), "[\"q1\"]") + ","
+ + item("103", "evidence", "가".repeat(150), "[\"q1\"]"),
+ item("104", "suggestion", "", "[\"q1\"]")));
+
+ assertThat(fit.matchedFocus()).extracting(CompanyFitResponse.Item::factId).containsExactly("103");
+ assertThat(fit.gaps()).isEmpty();
+ }
+
+ @Test
+ void 같은_목록의_같은_fact는_첫_유효_항목만_두고_목록끼리는_겹쳐도_된다() {
+ CompanyFitResponse fit = read(fit("코멘트",
+ item("102", "evidence", "근거 없음", "[\"q9\"]") + ","
+ + item("102", "evidence", "첫 유효", "[\"q1\"]") + ","
+ + item("102", "evidence", "두 번째", "[\"q2\"]"),
+ item("102", "suggestion", "보완", "[\"q2\"]")));
+
+ assertThat(fit.matchedFocus()).singleElement().extracting(CompanyFitResponse.Item::evidence)
+ .isEqualTo("첫 유효");
+ assertThat(fit.gaps()).singleElement().extracting(CompanyFitResponse.Gap::factId).isEqualTo("102");
+ }
+
+ @Test
+ void 목록마다_세_개까지_둔다() {
+ CompanyFitResponse fit = read(fit("코멘트",
+ item("101", "evidence", "근거", "[\"q1\"]") + ","
+ + item("102", "evidence", "근거", "[\"q1\"]") + ","
+ + item("103", "evidence", "근거", "[\"q1\"]") + ","
+ + item("104", "evidence", "근거", "[\"q1\"]"), ""));
+
+ assertThat(fit.matchedFocus()).extracting(CompanyFitResponse.Item::factId).containsExactly("101", "102", "103");
+ }
+
+ @Test
+ void 항목이_있었는데_모두_빠지면_전체를_비운다() {
+ assertThat(read(fit("코멘트",
+ item("999", "evidence", "근거", "[\"q1\"]"),
+ item("101", "suggestion", "보완", "[\"q9\"]")))).isNull();
+ }
+
+ @Test
+ void 처음부터_두_목록이_비었으면_총평만_둔다() {
+ CompanyFitResponse fit = read(fit("이번 답변에서는 관찰할 기회가 없었습니다.", "", ""));
+
+ assertThat(fit.comment()).isEqualTo("이번 답변에서는 관찰할 기회가 없었습니다.");
+ assertThat(fit.matchedFocus()).isEmpty();
+ assertThat(fit.gaps()).isEmpty();
+ assertThat(read("{ \"comment\": \"총평\" }").comment()).isEqualTo("총평");
+ }
+
+ @Test
+ void 총평이_비었거나_300자를_넘으면_자르지_않고_비운다() {
+ String item = item("101", "evidence", "근거", "[\"q1\"]");
+
+ assertThat(read(fit(" ", item, ""))).isNull();
+ assertThat(read(fit("가".repeat(301), item, ""))).isNull();
+ // 이모지 하나는 한 글자다(분석 서버 Python len과 같다).
+ assertThat(read(fit("😀".repeat(300), item, "")).comment()).hasSize(600);
+ }
+
+ @Test
+ void 모양이_틀리면_비운다() {
+ assertThat(read("[]")).isNull();
+ assertThat(read("\"문자열\"")).isNull();
+ assertThat(read("{ \"comment\": 3 }")).isNull();
+ assertThat(read("{ \"comment\": \"코멘트\", \"gaps\": {} }")).isNull();
+ // 항목 하나가 객체가 아니면 그 항목만 뺀다.
+ assertThat(read("{ \"comment\": \"코멘트\", \"matchedFocus\": [1, " + item("101", "evidence", "근거", "[\"q1\"]")
+ + "] }").matchedFocus()).hasSize(1);
+ }
+
+ @Test
+ void 기업을_고르지_않았거나_값이_없으면_비운다() {
+ JsonNode valid = JsonMapper.shared().readTree(fit("코멘트", "", ""));
+
+ assertThat(CompanyFitReader.read(valid, null, ANSWERED, 1L)).isNull();
+ assertThat(CompanyFitReader.read(null, SNAPSHOT, ANSWERED, 1L)).isNull();
+ assertThat(CompanyFitReader.read(JsonMapper.shared().readTree("null"), SNAPSHOT, ANSWERED, 1L)).isNull();
+ }
+}
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceCompanyTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceCompanyTest.java
index 357d9e29..82032280 100644
--- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceCompanyTest.java
+++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceCompanyTest.java
@@ -2,6 +2,7 @@
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
+import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
@@ -85,6 +86,9 @@ class FeedbackServiceCompanyTest {
private final String sessionId = UUID.randomUUID().toString();
private final String jobId = UUID.randomUUID().toString();
+ // 답변이 있는 문항과 답변이 없는 문항. 기업 관점 코멘트는 앞엣것만 근거로 댈 수 있다.
+ private String answeredQuestionId;
+ private String unansweredQuestionId;
private Long givenCompany() {
return companyRepository.save(CompanyEntity.builder()
@@ -126,26 +130,73 @@ private InterviewEntity givenFinishedInterview(CompanySnapshot snapshot) {
.content("조회가 쓰기보다 많아서요.")
.createdAt(LocalDateTime.now())
.build());
+ QuestionEntity unanswered = questionRepository.save(QuestionEntity.builder()
+ .interviewId(interview.getInterviewId())
+ .chatQuestionId(2L)
+ .type(Type.ORIGINAL)
+ .intention("장애 대응")
+ .content("장애를 겪은 경험이 있나요?")
+ .createdAt(LocalDateTime.now())
+ .build());
+ answeredQuestionId = String.valueOf(question.getQuestionId());
+ unansweredQuestionId = String.valueOf(unanswered.getQuestionId());
return interview;
}
- private FeedbackSoloRequest requested(CompanySnapshot snapshot) {
+ /** 다른 면접의 답변 있는 문항. 이 면접의 근거로 대면 안 된다. */
+ private String otherInterviewQuestionId() {
+ InterviewEntity other = interviewRepository.save(InterviewEntity.builder()
+ .userId(USER_ID)
+ .mode(InterviewMode.SOLO)
+ .status(Status.COMPLETED)
+ .sessionId(UUID.randomUUID().toString())
+ .build());
+ QuestionEntity question = questionRepository.save(QuestionEntity.builder()
+ .interviewId(other.getInterviewId())
+ .chatQuestionId(1L)
+ .type(Type.ORIGINAL)
+ .intention("다른 면접")
+ .content("다른 면접의 질문")
+ .createdAt(LocalDateTime.now())
+ .build());
+ answerRepository.save(AnswerEntity.builder()
+ .interviewId(other.getInterviewId())
+ .questionId(question.getQuestionId())
+ .userId(USER_ID)
+ .responseTime(10)
+ .content("다른 면접의 답변")
+ .createdAt(LocalDateTime.now())
+ .build());
+ return String.valueOf(question.getQuestionId());
+ }
+
+ /** 끝난 면접의 채점을 요청해 접수까지 마친다. 기업 관점 코멘트의 근거는 이때 보낸 문항으로 정해진다. */
+ private InterviewEntity givenRequested(CompanySnapshot snapshot) {
InterviewEntity interview = givenFinishedInterview(snapshot);
when(aiServerClient.requestSoloFeedback(any()))
.thenReturn(new FeedbackAcceptedResponse(jobId, sessionId, "accepted", null));
feedbackService.requestFeedback(USER_ID, interview.getInterviewId());
+ return interview;
+ }
+
+ private FeedbackSoloRequest requested(CompanySnapshot snapshot) {
+ givenRequested(snapshot);
ArgumentCaptor sent = ArgumentCaptor.forClass(FeedbackSoloRequest.class);
verify(aiServerClient).requestSoloFeedback(sent.capture());
return sent.getValue();
}
- private FeedbackEntity callback(JsonNode companyFit) {
+ private FeedbackCallbackRequest succeeded(JsonNode companyFit) {
FeedbackCallbackRequest.Overall overall = new FeedbackCallbackRequest.Overall(
72, 80, 61, "근거를 수치로 설명했습니다.", List.of(), List.of(), List.of(), 1, 1, null);
- feedbackService.handleCallback(new FeedbackCallbackRequest(jobId, sessionId, "succeeded",
- new FeedbackCallbackRequest.Result(overall, null, List.of(), companyFit), null));
+ return new FeedbackCallbackRequest(jobId, sessionId, "succeeded",
+ new FeedbackCallbackRequest.Result(overall, null, List.of(), companyFit), null);
+ }
+
+ private FeedbackEntity callback(JsonNode companyFit) {
+ feedbackService.handleCallback(succeeded(companyFit));
entityManager.flush();
entityManager.clear();
return feedbackRepository.findByJobId(jobId).orElseThrow();
@@ -175,18 +226,18 @@ private static JsonNode json(String text) {
@Test
void 콜백의_기업_관점_코멘트를_저장하고_조회에_싣는다() {
- InterviewEntity interview = givenFinishedInterview(SNAPSHOT);
+ InterviewEntity interview = givenRequested(SNAPSHOT);
FeedbackEntity saved = callback(json("""
{
"comment": "샘플커머스가 중요하게 보는 오너십 관점에서는 원인을 끝까지 추적한 점이 돋보입니다.",
"matchedFocus": [
{ "factId": "23", "kind": "TALENT", "focus": "오너십",
- "evidence": "장애 원인을 직접 추적했습니다.", "questionIds": ["901"] }
+ "evidence": "장애 원인을 직접 추적했습니다.", "questionIds": ["%s"] }
],
"gaps": [],
"unknownField": true
- }"""));
+ }""".formatted(answeredQuestionId)));
assertThat(saved.getStatus()).isEqualTo(FeedbackStatus.SUCCEEDED);
CompanyFitResponse fit = saved.getCompanyFit();
@@ -194,7 +245,7 @@ private static JsonNode json(String text) {
assertThat(fit.matchedFocus()).singleElement().satisfies(item -> {
assertThat(item.factId()).isEqualTo("23");
assertThat(item.focus()).isEqualTo("오너십");
- assertThat(item.questionIds()).containsExactly("901");
+ assertThat(item.questionIds()).containsExactly(answeredQuestionId);
});
assertThat(fit.gaps()).isEmpty();
@@ -217,6 +268,155 @@ private static JsonNode json(String text) {
}
}
+ /**
+ * 근거는 이 면접에서 답변이 있는 문항이어야 한다. 답하지 않은 문항, 다른 면접의 문항만 댄 항목은 빼고, kind·focus는
+ * 모델이 쓴 값 대신 스냅샷 fact에서 채운다. 기본 피드백은 그대로 저장한다.
+ */
+ @Test
+ void 이_면접의_답변_있는_문항만_근거로_남긴다() {
+ givenRequested(SNAPSHOT);
+ String otherQuestionId = otherInterviewQuestionId();
+
+ FeedbackEntity saved = callback(json("""
+ {
+ "comment": "오너십 관점에서 원인을 끝까지 따라간 설명이 보였습니다.",
+ "matchedFocus": [
+ { "factId": "23", "kind": "BUSINESS", "focus": "지어낸 이름",
+ "evidence": "원인을 직접 추적했습니다.", "questionIds": ["%1$s", "%2$s", "%3$s", "%1$s"] },
+ { "factId": "21", "evidence": "다른 면접 답변만 댔습니다.", "questionIds": ["%3$s"] }
+ ],
+ "gaps": [
+ { "factId": "21", "suggestion": "답하지 않은 문항만 댔습니다.", "questionIds": ["%2$s"] }
+ ]
+ }""".formatted(answeredQuestionId, unansweredQuestionId, otherQuestionId)));
+
+ assertThat(saved.getStatus()).isEqualTo(FeedbackStatus.SUCCEEDED);
+ assertThat(saved.getTotalScore()).isEqualTo(72);
+ CompanyFitResponse fit = saved.getCompanyFit();
+ assertThat(fit.matchedFocus()).singleElement().satisfies(item -> {
+ assertThat(item.kind()).isEqualTo("TALENT");
+ assertThat(item.focus()).isEqualTo("오너십");
+ assertThat(item.questionIds()).containsExactly(answeredQuestionId);
+ });
+ assertThat(fit.gaps()).isEmpty();
+ }
+
+ /** 항목이 있었는데 모두 근거가 맞지 않으면 총평만 살리지 않는다. 기본 피드백은 저장한다. */
+ @Test
+ void 항목이_모두_빠지면_기업_관점_코멘트는_비운다() {
+ givenRequested(SNAPSHOT);
+
+ FeedbackEntity saved = callback(json("""
+ {
+ "comment": "오너십이 잘 드러났습니다.",
+ "matchedFocus": [
+ { "factId": "999", "evidence": "없는 fact", "questionIds": ["%1$s"] },
+ { "factId": "23", "evidence": "답하지 않은 문항", "questionIds": ["%2$s"] }
+ ],
+ "gaps": []
+ }""".formatted(answeredQuestionId, unansweredQuestionId)));
+
+ assertThat(saved.getStatus()).isEqualTo(FeedbackStatus.SUCCEEDED);
+ assertThat(saved.getTotalScore()).isEqualTo(72);
+ assertThat(saved.getCompanyFit()).isNull();
+ }
+
+ /**
+ * 채점을 요청한 뒤 콜백이 오기 전에 답변 API로 답변이 더해져도, 그 문항은 이 작업이 보낸 것이 아니라 근거가 되지 않는다.
+ * 근거는 요청할 때 남긴 문항 목록으로 가린다.
+ */
+ @Test
+ void 요청_뒤에_더해진_답변의_문항은_근거로_인정하지_않는다() {
+ InterviewEntity interview = givenRequested(SNAPSHOT);
+ answerRepository.save(AnswerEntity.builder()
+ .interviewId(interview.getInterviewId())
+ .questionId(Long.valueOf(unansweredQuestionId))
+ .userId(USER_ID)
+ .responseTime(30)
+ .content("요청 뒤에 저장한 답변")
+ .createdAt(LocalDateTime.now())
+ .build());
+
+ FeedbackEntity saved = callback(json("""
+ {
+ "comment": "오너십 관점에서 원인을 끝까지 따라간 설명이 보였습니다.",
+ "matchedFocus": [
+ { "factId": "23", "evidence": "원인을 직접 추적했습니다.", "questionIds": ["%1$s", "%2$s"] }
+ ],
+ "gaps": [
+ { "factId": "21", "suggestion": "요청 뒤 답변만 댔습니다.", "questionIds": ["%2$s"] }
+ ]
+ }""".formatted(answeredQuestionId, unansweredQuestionId)));
+
+ assertThat(saved.getRequestedQuestionIds()).containsExactly(answeredQuestionId);
+ assertThat(saved.getCompanyFit().matchedFocus()).singleElement()
+ .extracting(CompanyFitResponse.Item::questionIds).asInstanceOf(InstanceOfAssertFactories.LIST)
+ .containsExactly(answeredQuestionId);
+ assertThat(saved.getCompanyFit().gaps()).isEmpty();
+ }
+
+ /**
+ * 콜백이 접수 응답보다 먼저 와 작업 행이 없어도, 보내기 직전에 남긴 목록으로 근거를 가린다. 늦게 온 접수 응답은 그 행에
+ * 이 작업의 목록을 채운다.
+ */
+ @Test
+ void 콜백이_접수_응답보다_먼저_와도_보낸_목록으로_근거를_가린다() {
+ InterviewEntity interview = givenFinishedInterview(SNAPSHOT);
+ JsonNode companyFit = json("""
+ {
+ "comment": "오너십 관점에서 원인을 끝까지 따라간 설명이 보였습니다.",
+ "matchedFocus": [
+ { "factId": "23", "evidence": "원인을 직접 추적했습니다.", "questionIds": ["%1$s", "%2$s"] }
+ ]
+ }""".formatted(answeredQuestionId, unansweredQuestionId));
+ when(aiServerClient.requestSoloFeedback(any())).thenAnswer(invocation -> {
+ feedbackService.handleCallback(succeeded(companyFit));
+ return new FeedbackAcceptedResponse(jobId, sessionId, "accepted", null);
+ });
+
+ feedbackService.requestFeedback(USER_ID, interview.getInterviewId());
+ entityManager.flush();
+ entityManager.clear();
+
+ FeedbackEntity saved = feedbackRepository.findByJobId(jobId).orElseThrow();
+ assertThat(saved.getStatus()).isEqualTo(FeedbackStatus.SUCCEEDED);
+ assertThat(saved.getCompanyFit().matchedFocus()).singleElement()
+ .extracting(CompanyFitResponse.Item::questionIds).asInstanceOf(InstanceOfAssertFactories.LIST)
+ .containsExactly(answeredQuestionId);
+ assertThat(saved.getRequestedQuestionIds()).containsExactly(answeredQuestionId);
+ }
+
+ /** 문항 목록을 남기기 전에 보낸 요청의 콜백은 어느 문항도 근거로 인정하지 않는다. 총평만 있는 코멘트는 둔다. */
+ @Test
+ void 보낸_문항_목록이_없으면_근거_있는_항목을_인정하지_않는다() {
+ givenFinishedInterview(SNAPSHOT);
+
+ FeedbackEntity withItems = callback(json("""
+ {
+ "comment": "오너십이 잘 드러났습니다.",
+ "matchedFocus": [ { "factId": "23", "evidence": "근거", "questionIds": ["%s"] } ]
+ }""".formatted(answeredQuestionId)));
+ assertThat(withItems.getStatus()).isEqualTo(FeedbackStatus.SUCCEEDED);
+ assertThat(withItems.getCompanyFit()).isNull();
+
+ FeedbackEntity commentOnly = callback(json("""
+ { "comment": "이번 답변에서는 관찰할 기회가 없었습니다.", "matchedFocus": [], "gaps": [] }"""));
+ assertThat(commentOnly.getCompanyFit().comment()).isEqualTo("이번 답변에서는 관찰할 기회가 없었습니다.");
+ }
+
+ /** 기업을 고르지 않은 면접에 코멘트가 와도 잇지 않는다. */
+ @Test
+ void 기업이_없는_면접에_온_기업_관점_코멘트는_버린다() {
+ InterviewEntity interview = givenFinishedInterview(null);
+
+ FeedbackEntity saved = callback(json("""
+ { "comment": "관찰할 기회가 없었습니다.", "matchedFocus": [], "gaps": [] }"""));
+
+ assertThat(saved.getStatus()).isEqualTo(FeedbackStatus.SUCCEEDED);
+ assertThat(saved.getCompanyFit()).isNull();
+ assertThat(feedbackService.getFeedback(USER_ID, interview.getInterviewId()).getCompanyFit()).isNull();
+ }
+
@Test
void 기업이_없는_면접의_피드백_조회에는_기업이_비어_있다() {
InterviewEntity interview = givenFinishedInterview(null);
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewControllerCreateTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewControllerCreateTest.java
new file mode 100644
index 00000000..1c6d052f
--- /dev/null
+++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewControllerCreateTest.java
@@ -0,0 +1,194 @@
+package repit.repit_api_server.domain.userdata.interview.controller;
+
+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.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.http.MediaType;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.SimpleTransactionStatus;
+import org.springframework.transaction.support.TransactionTemplate;
+import repit.repit_api_server.domain.company.dto.CompanyBadge;
+import repit.repit_api_server.domain.userdata.answer.service.AnswerService;
+import repit.repit_api_server.domain.userdata.interview.dto.request.CreateInterviewRequest;
+import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse;
+import repit.repit_api_server.domain.userdata.interview.entity.InterviewCreateRequestEntity;
+import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode;
+import repit.repit_api_server.domain.userdata.interview.entity.enums.Status;
+import repit.repit_api_server.domain.userdata.interview.repository.InterviewCreateRequestRepository;
+import repit.repit_api_server.domain.userdata.interview.service.ChatInterviewResultService;
+import repit.repit_api_server.domain.userdata.interview.service.InterviewCreateService;
+import repit.repit_api_server.domain.userdata.interview.service.InterviewService;
+import repit.repit_api_server.global.auth.AuthUser;
+import repit.repit_api_server.global.error.GlobalExceptionHandler;
+import repit.repit_api_server.global.response.UserResponse;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 최종 면접 생성의 요청 형식(00 §3-2, §8). 헤더·ID 형식 오류는 422 INVALID_INTERVIEW_REQUEST이고, 본문에 실려 온 기업 정보는
+ * 쓰지 않는다. 키 저장소의 유일 제약·트랜잭션은 실제 DB 테스트(InterviewCreateIdempotencyTest)에서 본다.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class InterviewControllerCreateTest {
+
+ private static final Long USER_ID = 7L;
+ private static final String KEY = "3f2b8c1e-9a4d-4e6f-8b7a-1c2d3e4f5a6b";
+
+ @Mock
+ private InterviewService interviewService;
+ @Mock
+ private InterviewCreateRequestRepository interviewCreateRequestRepository;
+ @Mock
+ private PlatformTransactionManager transactionManager;
+
+ private MockMvc mockMvc;
+
+ @BeforeEach
+ void setUp() {
+ when(transactionManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus());
+ InterviewCreateService createService = new InterviewCreateService(interviewService,
+ interviewCreateRequestRepository, new TransactionTemplate(transactionManager));
+ mockMvc = MockMvcBuilders.standaloneSetup(new InterviewController(interviewService, createService,
+ (ChatInterviewResultService) null, (AnswerService) null))
+ .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver())
+ .setControllerAdvice(new GlobalExceptionHandler())
+ .build();
+
+ UserResponse user = new UserResponse();
+ ReflectionTestUtils.setField(user, "id", USER_ID);
+ SecurityContextHolder.getContext().setAuthentication(
+ new UsernamePasswordAuthenticationToken(new AuthUser(user, "Bearer token"), null, List.of()));
+
+ when(interviewService.createInterview(eq(USER_ID), any())).thenReturn(response(41L));
+ when(interviewService.getInterviewById(USER_ID, 41L)).thenReturn(response(41L));
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ private static InterviewResponse response(Long interviewId) {
+ return new InterviewResponse(interviewId, USER_ID, InterviewMode.SOLO, 1L, List.of(), "session",
+ Status.IN_PROGRESS, LocalDateTime.now(), new CompanyBadge(12L, "샘플커머스"));
+ }
+
+ private static MockHttpServletRequestBuilder create(String body) {
+ return post("/api/interviews/create").contentType(MediaType.APPLICATION_JSON).content(body);
+ }
+
+ @Test
+ void 키_없는_요청은_예전처럼_만든다() throws Exception {
+ mockMvc.perform(create("{\"personaId\": 1, \"companyId\": 12}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.interviewId").value(41))
+ .andExpect(jsonPath("$.data.company.companyId").value(12))
+ .andExpect(jsonPath("$.data.company.displayName").value("샘플커머스"));
+
+ verify(interviewCreateRequestRepository, never()).findByUserIdAndIdempotencyKey(any(), any());
+ }
+
+ @Test
+ void UUID가_아닌_키는_422다() throws Exception {
+ for (String key : List.of("not-a-uuid", "", "1-1-1-1-1", KEY + "0")) {
+ mockMvc.perform(create("{\"personaId\": 1}").header("Idempotency-Key", key))
+ .andExpect(status().isUnprocessableContent())
+ .andExpect(jsonPath("$.code").value("INVALID_INTERVIEW_REQUEST"))
+ .andExpect(jsonPath("$.message").exists());
+ }
+ verify(interviewService, never()).createInterview(any(), any());
+ }
+
+ /** 웹은 id를 JavaScript number로 다룬다. 0 이하나 안전한 정수 범위를 넘는 값은 받지 않는다. */
+ @Test
+ void 범위를_벗어난_companyId는_422다() throws Exception {
+ for (String companyId : List.of("0", "-3", "9007199254740992")) {
+ mockMvc.perform(create("{\"personaId\": 1, \"companyId\": " + companyId + "}"))
+ .andExpect(status().isUnprocessableContent())
+ .andExpect(jsonPath("$.code").value("INVALID_INTERVIEW_REQUEST"));
+ }
+ mockMvc.perform(create("{\"personaId\": 1, \"companyId\": 9007199254740991}"))
+ .andExpect(status().isOk());
+ }
+
+ /** 브라우저가 보낸 기업 정보는 생성 근거가 아니다. 서버는 companyId만 받아 DB에서 다시 만든다. */
+ @Test
+ void 본문에_실려_온_기업_정보는_버린다() throws Exception {
+ mockMvc.perform(create("""
+ {
+ "personaId": 1,
+ "companyId": 12,
+ "companySnapshot": { "displayName": "위조 기업" },
+ "facts": [{ "factId": "1", "kind": "TALENT", "title": "위조", "content": "위조" }],
+ "interviewerBrief": "위조 요약",
+ "review": { "reviewedBy": "위조" }
+ }"""))
+ .andExpect(status().isOk());
+
+ ArgumentCaptor sent = ArgumentCaptor.forClass(CreateInterviewRequest.class);
+ verify(interviewService).createInterview(eq(USER_ID), sent.capture());
+ assertThat(sent.getValue().getCompanyId()).isEqualTo(12L);
+ assertThat(sent.getValue().getPersonaId()).isEqualTo(1L);
+ }
+
+ @Test
+ void 같은_키에_다른_설정이면_409다() throws Exception {
+ when(interviewCreateRequestRepository.findByUserIdAndIdempotencyKey(USER_ID, UUID.fromString(KEY)))
+ .thenReturn(Optional.of(InterviewCreateRequestEntity.builder()
+ .userId(USER_ID).idempotencyKey(UUID.fromString(KEY)).requestHash("0".repeat(64))
+ .interviewId(41L).build()));
+
+ mockMvc.perform(create("{\"personaId\": 1, \"companyId\": 12}").header("Idempotency-Key", KEY))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value("IDEMPOTENCY_KEY_REUSED"));
+ verify(interviewService, never()).createInterview(any(), any());
+ verify(interviewService, never()).getInterviewById(any(), any());
+ }
+
+ /** 대문자로 보낸 키도 같은 키다. 처음 요청이 키를 저장하고, 다시 보낸 요청은 저장된 면접을 그대로 받는다. */
+ @Test
+ void 같은_키_같은_설정이면_저장된_면접을_돌려준다() throws Exception {
+ String body = "{\"personaId\": 1, \"companyId\": 12}";
+ mockMvc.perform(create(body).header("Idempotency-Key", KEY)).andExpect(status().isOk());
+ ArgumentCaptor saved = ArgumentCaptor.forClass(InterviewCreateRequestEntity.class);
+ verify(interviewCreateRequestRepository).saveAndFlush(saved.capture());
+ assertThat(saved.getValue().getIdempotencyKey()).isEqualTo(UUID.fromString(KEY));
+ assertThat(saved.getValue().getInterviewId()).isEqualTo(41L);
+
+ when(interviewCreateRequestRepository.findByUserIdAndIdempotencyKey(USER_ID, UUID.fromString(KEY)))
+ .thenReturn(Optional.of(saved.getValue()));
+ // 필드 순서가 달라도 같은 설정이다.
+ mockMvc.perform(create("{\"companyId\": 12, \"personaId\": 1}").header("Idempotency-Key", KEY.toUpperCase()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.interviewId").value(41));
+ verify(interviewService).createInterview(eq(USER_ID), any());
+ verify(interviewService).getInterviewById(USER_ID, 41L);
+ }
+}
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateIdempotencyTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateIdempotencyTest.java
new file mode 100644
index 00000000..d5406bf3
--- /dev/null
+++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateIdempotencyTest.java
@@ -0,0 +1,435 @@
+package repit.repit_api_server.domain.userdata.interview.service;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.transaction.support.TransactionTemplate;
+import repit.repit_api_server.domain.company.dto.CompanyBadge;
+import repit.repit_api_server.domain.company.dto.CompanySnapshot;
+import repit.repit_api_server.domain.company.entity.CompanyEntity;
+import repit.repit_api_server.domain.company.entity.CompanyFactEntity;
+import repit.repit_api_server.domain.company.entity.CompanySourceEntity;
+import repit.repit_api_server.domain.company.entity.enums.CompanyCategory;
+import repit.repit_api_server.domain.company.entity.enums.CompanyFactKind;
+import repit.repit_api_server.domain.company.entity.enums.CompanySourceType;
+import repit.repit_api_server.domain.company.entity.enums.CompanyStatus;
+import repit.repit_api_server.domain.company.repository.CompanyFactRepository;
+import repit.repit_api_server.domain.company.repository.CompanyRepository;
+import repit.repit_api_server.domain.company.repository.CompanySourceRepository;
+import repit.repit_api_server.domain.company.service.CompanySnapshotFactory;
+import repit.repit_api_server.domain.userdata.interview.dto.request.CreateInterviewRequest;
+import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse;
+import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository;
+import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity;
+import repit.repit_api_server.domain.userdata.persona.entity.enums.Gender;
+import repit.repit_api_server.domain.userdata.persona.entity.enums.InterviewTone;
+import repit.repit_api_server.domain.userdata.persona.entity.enums.Level;
+import repit.repit_api_server.domain.userdata.persona.entity.enums.Major;
+import repit.repit_api_server.domain.userdata.persona.entity.enums.Role;
+import repit.repit_api_server.domain.userdata.persona.entity.enums.Type;
+import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository;
+import repit.repit_api_server.global.error.ErrorCodes;
+import repit.repit_api_server.global.exception.BusinessException;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * 최종 면접 생성의 중복 방지·원자성·기업 잠금(02 §5, §3-3)을 실제 PostgreSQL에 대고 본다.
+ *
+ * 유일 제약과 트랜잭션 롤백, 행 잠금은 테스트 더블로 확인할 수 없다. 요청마다 자기 트랜잭션에서 돌도록 테스트 트랜잭션으로
+ * 감싸지 않고, 끝나면 이 테스트가 만든 사용자·기업·면접관을 지운다.
+ */
+@SpringBootTest
+class InterviewCreateIdempotencyTest {
+
+ @Autowired
+ private InterviewCreateService createService;
+ @Autowired
+ private CompanySnapshotFactory companySnapshotFactory;
+ @Autowired
+ private InterviewRepository interviewRepository;
+ @Autowired
+ private PersonaRepository personaRepository;
+ @Autowired
+ private CompanyRepository companyRepository;
+ @Autowired
+ private CompanySourceRepository companySourceRepository;
+ @Autowired
+ private CompanyFactRepository companyFactRepository;
+ @Autowired
+ private TransactionTemplate transactionTemplate;
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ // 다른 테스트·로컬 데이터와 겹치지 않는 사용자와 이름.
+ private final long userId = ThreadLocalRandom.current().nextLong(1_000_000_000L, 2_000_000_000L);
+ private final long otherUserId = userId + 1;
+ private final String suffix = UUID.randomUUID().toString().substring(0, 8);
+ private final String companyCode = "idem-" + suffix;
+
+ private Long techId;
+ private Long hrId;
+ private Long companyId;
+
+ @BeforeEach
+ void setUp() {
+ techId = persona("기술-" + suffix, Role.TECH);
+ hrId = persona("인사-" + suffix, Role.HR);
+ companyId = company(companyCode, true);
+ }
+
+ @AfterEach
+ void cleanUp() {
+ // 요청 키는 면접을 지우면 함께 지워진다(ON DELETE CASCADE).
+ jdbcTemplate.update("delete from interview where user_id in (?, ?)", userId, otherUserId);
+ jdbcTemplate.update("delete from company_fact where company_id in (select company_id from company where code like ?)",
+ companyCode + "%");
+ jdbcTemplate.update("delete from company_source where company_id in (select company_id from company where code like ?)",
+ companyCode + "%");
+ jdbcTemplate.update("delete from company where code like ?", companyCode + "%");
+ jdbcTemplate.update("delete from persona where name like ?", "%-" + suffix);
+ }
+
+ private Long persona(String name, Role role) {
+ return personaRepository.save(PersonaEntity.builder()
+ .personaName(name)
+ .role(role)
+ .major(Major.BACKEND)
+ .type(Type.REALISTIC)
+ .tone(InterviewTone.GENTLE)
+ .level(Level.NORMAL)
+ .career(5)
+ .gender(Gender.FEMALE)
+ .build()).getPersonaId();
+ }
+
+ /** 검수를 마친 공개 기업. talent가 false면 인재상이 없어 실행 스냅샷을 만들 수 없다. */
+ private Long company(String code, boolean talent) {
+ return transactionTemplate.execute(status -> {
+ CompanyEntity company = companyRepository.save(CompanyEntity.builder()
+ .code(code)
+ .displayName("멱등커머스")
+ .legalName("멱등커머스 주식회사")
+ .aliases(List.of())
+ .category(CompanyCategory.CONSUMER)
+ .status(CompanyStatus.PUBLISHED)
+ .interviewerBrief("멱등커머스는 가상의 기업이다.")
+ .dataAsOf(LocalDate.parse("2026-10-15"))
+ .reviewedBy("검수자")
+ .reviewedAt(LocalDate.parse("2026-10-16"))
+ .contentHash("0".repeat(64))
+ .build());
+ Long sourceId = companySourceRepository.save(CompanySourceEntity.builder()
+ .companyId(company.getCompanyId())
+ .sourceKey("about")
+ .sourceType(CompanySourceType.CAREER_PAGE)
+ .url("https://example.invalid/about")
+ .collectedAt(LocalDate.parse("2026-10-15"))
+ .build()).getSourceId();
+ List facts = new ArrayList<>();
+ facts.add(fact(company.getCompanyId(), sourceId, CompanyFactKind.BUSINESS, "멱등마켓", 0));
+ if (talent) {
+ facts.add(fact(company.getCompanyId(), sourceId, CompanyFactKind.TALENT, "오너십", 1));
+ }
+ companyFactRepository.saveAll(facts);
+ return company.getCompanyId();
+ });
+ }
+
+ private static CompanyFactEntity fact(Long companyId, Long sourceId, CompanyFactKind kind, String title, int order) {
+ return CompanyFactEntity.builder()
+ .companyId(companyId)
+ .kind(kind)
+ .title(title)
+ .content(title + "에 대한 설명")
+ .positiveSignals(List.of())
+ .concernSignals(List.of())
+ .sourceId(sourceId)
+ .sortOrder(order)
+ .build();
+ }
+
+ private CreateInterviewRequest solo(Long companyId) {
+ return new CreateInterviewRequest(techId, null, null, companyId);
+ }
+
+ private CreateInterviewRequest multi(Long companyId) {
+ return new CreateInterviewRequest(null, null, List.of(hrId, techId), companyId);
+ }
+
+ private int interviews(long user) {
+ return jdbcTemplate.queryForObject("select count(*) from interview where user_id = ?", Integer.class, user);
+ }
+
+ private int keys(long user) {
+ return jdbcTemplate.queryForObject("select count(*) from interview_create_request where user_id = ?",
+ Integer.class, user);
+ }
+
+ private CompanySnapshot snapshotOf(Long interviewId) {
+ return interviewRepository.findById(interviewId).orElseThrow().getCompanySnapshot();
+ }
+
+ private static void assertError(Runnable call, HttpStatus status, String code) {
+ assertThatThrownBy(call::run)
+ .isInstanceOf(BusinessException.class)
+ .satisfies(e -> {
+ assertThat(((BusinessException) e).getStatus()).isEqualTo(status);
+ assertThat(((BusinessException) e).getCode()).isEqualTo(code);
+ });
+ }
+
+ @Test
+ void 같은_키_같은_설정이면_처음_만든_면접을_돌려준다() {
+ String key = UUID.randomUUID().toString();
+
+ InterviewResponse first = createService.create(userId, key, solo(companyId));
+ InterviewResponse again = createService.create(userId, key, solo(companyId));
+
+ assertThat(again.getInterviewId()).isEqualTo(first.getInterviewId());
+ assertThat(again.getCompany()).isEqualTo(new CompanyBadge(companyId, "멱등커머스"));
+ assertThat(interviews(userId)).isEqualTo(1);
+ assertThat(keys(userId)).isEqualTo(1);
+ assertThat(snapshotOf(first.getInterviewId()).facts()).extracting(CompanySnapshot.Fact::title)
+ .containsExactly("멱등마켓", "오너십");
+ }
+
+ @Test
+ void N대1도_같은_키면_같은_면접과_면접관_순서를_돌려준다() {
+ String key = UUID.randomUUID().toString();
+
+ InterviewResponse first = createService.create(userId, key, multi(companyId));
+ InterviewResponse again = createService.create(userId, key, multi(companyId));
+
+ assertThat(again.getInterviewId()).isEqualTo(first.getInterviewId());
+ assertThat(again.getPersonaIds()).containsExactly(techId, hrId);
+ assertThat(jdbcTemplate.queryForObject("select count(*) from interview_persona where interview_id = ?",
+ Integer.class, first.getInterviewId())).isEqualTo(2);
+ }
+
+ @Test
+ void 같은_키로_다른_설정을_보내면_409이고_처음_면접은_그대로다() {
+ String key = UUID.randomUUID().toString();
+ InterviewResponse first = createService.create(userId, key, solo(companyId));
+
+ assertError(() -> createService.create(userId, key, solo(null)),
+ HttpStatus.CONFLICT, ErrorCodes.IDEMPOTENCY_KEY_REUSED);
+ assertError(() -> createService.create(userId, key, multi(companyId)),
+ HttpStatus.CONFLICT, ErrorCodes.IDEMPOTENCY_KEY_REUSED);
+
+ assertThat(interviews(userId)).isEqualTo(1);
+ assertThat(snapshotOf(first.getInterviewId())).isNotNull();
+ }
+
+ @Test
+ void 다른_사용자는_같은_키로도_따로_만든다() {
+ String key = UUID.randomUUID().toString();
+
+ InterviewResponse mine = createService.create(userId, key, solo(companyId));
+ InterviewResponse theirs = createService.create(otherUserId, key, solo(companyId));
+
+ assertThat(theirs.getInterviewId()).isNotEqualTo(mine.getInterviewId());
+ assertThat(theirs.getUserId()).isEqualTo(otherUserId);
+ }
+
+ @Test
+ void 키가_없으면_예전처럼_요청마다_만든다() {
+ createService.create(userId, null, solo(companyId));
+ createService.create(userId, null, solo(companyId));
+
+ assertThat(interviews(userId)).isEqualTo(2);
+ assertThat(keys(userId)).isZero();
+ }
+
+ /** 시작 버튼 연타·네트워크 재시도. 유일 제약이 하나만 남기고, 진 요청은 롤백된 뒤 같은 면접을 받는다. */
+ @Test
+ void 같은_키가_동시에_와도_면접은_하나다() throws Exception {
+ String key = UUID.randomUUID().toString();
+ int concurrent = 6;
+ ExecutorService pool = Executors.newFixedThreadPool(concurrent);
+ CountDownLatch start = new CountDownLatch(1);
+ try {
+ List> results = new ArrayList<>();
+ for (int i = 0; i < concurrent; i++) {
+ results.add(pool.submit(() -> {
+ start.await();
+ return createService.create(userId, key, multi(companyId));
+ }));
+ }
+ start.countDown();
+
+ List ids = new ArrayList<>();
+ for (Future result : results) {
+ ids.add(result.get(30, TimeUnit.SECONDS).getInterviewId());
+ }
+ assertThat(ids).containsOnly(ids.getFirst());
+ } finally {
+ pool.shutdownNow();
+ }
+ assertThat(interviews(userId)).isEqualTo(1);
+ assertThat(keys(userId)).isEqualTo(1);
+ // 진 요청이 넣었던 면접관 명단도 롤백됐다.
+ assertThat(jdbcTemplate.queryForObject("""
+ select count(*) from interview_persona ip join interview i on i.interview_id = ip.interview_id
+ where i.user_id = ?""", Integer.class, userId)).isEqualTo(2);
+ }
+
+ /** 성공한 요청의 재전송은 기업 검증을 다시 하지 않는다. 새 키로는 숨긴 기업을 고를 수 없다. */
+ @Test
+ void 기업이_숨겨진_뒤에도_같은_키_재전송은_저장된_면접을_돌려준다() {
+ String key = UUID.randomUUID().toString();
+ InterviewResponse first = createService.create(userId, key, solo(companyId));
+ jdbcTemplate.update("update company set status = 'HIDDEN' where company_id = ?", companyId);
+
+ InterviewResponse again = createService.create(userId, key, solo(companyId));
+
+ assertThat(again.getInterviewId()).isEqualTo(first.getInterviewId());
+ assertThat(again.getCompany()).isEqualTo(new CompanyBadge(companyId, "멱등커머스"));
+ assertError(() -> createService.create(userId, UUID.randomUUID().toString(), solo(companyId)),
+ HttpStatus.NOT_FOUND, ErrorCodes.COMPANY_UNAVAILABLE);
+ assertThat(interviews(userId)).isEqualTo(1);
+ }
+
+ /** 검증에 실패한 요청은 면접도 키도 남기지 않는다. 고친 뒤 같은 키로 다시 보내면 만들어진다. */
+ @Test
+ void 실패한_생성은_면접도_키도_남기지_않는다() {
+ String key = UUID.randomUUID().toString();
+ jdbcTemplate.update("update company set status = 'HIDDEN' where company_id = ?", companyId);
+
+ assertError(() -> createService.create(userId, key, multi(companyId)),
+ HttpStatus.NOT_FOUND, ErrorCodes.COMPANY_UNAVAILABLE);
+ assertThatThrownBy(() -> createService.create(userId, key, new CreateInterviewRequest(null, null,
+ List.of(hrId), null))).isInstanceOf(BusinessException.class);
+ assertThat(interviews(userId)).isZero();
+ assertThat(keys(userId)).isZero();
+
+ jdbcTemplate.update("update company set status = 'PUBLISHED' where company_id = ?", companyId);
+ InterviewResponse created = createService.create(userId, key, multi(companyId));
+ assertThat(created.getCompany()).isEqualTo(new CompanyBadge(companyId, "멱등커머스"));
+ assertThat(interviews(userId)).isEqualTo(1);
+ }
+
+ /** 공개 기업인데 스냅샷을 만들 수 없으면 503이다. 기업 없는 면접으로 바꿔 만들지 않는다. */
+ @Test
+ void 실행_스냅샷을_만들_수_없으면_503이고_면접을_만들지_않는다() {
+ Long broken = company(companyCode + "-broken", false);
+
+ assertError(() -> createService.create(userId, UUID.randomUUID().toString(), solo(broken)),
+ HttpStatus.SERVICE_UNAVAILABLE, ErrorCodes.COMPANY_CONTEXT_UNAVAILABLE);
+ assertError(() -> createService.create(userId, null, multi(broken)),
+ HttpStatus.SERVICE_UNAVAILABLE, ErrorCodes.COMPANY_CONTEXT_UNAVAILABLE);
+ assertThat(interviews(userId)).isZero();
+ assertThat(keys(userId)).isZero();
+ }
+
+ /** 생성 뒤 기업 정보가 바뀌어도 그 면접은 만들 때의 스냅샷을 그대로 둔다. */
+ @Test
+ void 기업이_바뀌어도_만든_면접의_스냅샷은_그대로다() {
+ InterviewResponse created = createService.create(userId, null, solo(companyId));
+
+ jdbcTemplate.update("update company set display_name = '새이름', interviewer_brief = '새 요약' where company_id = ?",
+ companyId);
+ jdbcTemplate.update("update company_fact set title = '새인재상' where company_id = ? and kind = 'TALENT'", companyId);
+
+ CompanySnapshot snapshot = snapshotOf(created.getInterviewId());
+ assertThat(snapshot.displayName()).isEqualTo("멱등커머스");
+ assertThat(snapshot.interviewerBrief()).isEqualTo("멱등커머스는 가상의 기업이다.");
+ assertThat(snapshot.facts()).extracting(CompanySnapshot.Fact::title).containsExactly("멱등마켓", "오너십");
+ }
+
+ /**
+ * 가져오기가 기업 행을 FOR UPDATE로 잡고 요약·fact를 바꾸는 동안 면접 생성은 기다린다. 끝난 뒤 만든 스냅샷은 바뀐 요약과
+ * 바뀐 fact를 함께 담는다 — 이전 요약과 새 fact가 섞이지 않는다.
+ */
+ @Test
+ void 가져오기가_기업을_고치는_동안_스냅샷은_기다렸다가_새_값만_담는다() throws Exception {
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ CountDownLatch locked = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ try {
+ Future> importing = pool.submit(() -> transactionTemplate.executeWithoutResult(status -> {
+ CompanyEntity company = companyRepository.findForUpdateByCode(companyCode).orElseThrow();
+ Long sourceId = companySourceRepository.findAll().stream()
+ .filter(source -> source.getCompanyId().equals(companyId))
+ .findFirst().orElseThrow().getSourceId();
+ company.setInterviewerBrief("바뀐 요약");
+ companyFactRepository.deleteAllByCompanyId(companyId);
+ companyFactRepository.saveAllAndFlush(List.of(
+ fact(companyId, sourceId, CompanyFactKind.BUSINESS, "새마켓", 0),
+ fact(companyId, sourceId, CompanyFactKind.TALENT, "새인재상", 1)));
+ locked.countDown();
+ await(release);
+ }));
+ assertThat(locked.await(10, TimeUnit.SECONDS)).isTrue();
+
+ Future creating = pool.submit(() -> createService.create(userId, null, solo(companyId)));
+ Thread.sleep(500);
+ assertThat(creating.isDone()).as("가져오기가 끝나기 전에 스냅샷을 만들면 안 된다").isFalse();
+
+ release.countDown();
+ importing.get(10, TimeUnit.SECONDS);
+ CompanySnapshot snapshot = snapshotOf(creating.get(10, TimeUnit.SECONDS).getInterviewId());
+ assertThat(snapshot.interviewerBrief()).isEqualTo("바뀐 요약");
+ assertThat(snapshot.facts()).extracting(CompanySnapshot.Fact::title).containsExactly("새마켓", "새인재상");
+ } finally {
+ release.countDown();
+ pool.shutdownNow();
+ }
+ }
+
+ /** 반대로 스냅샷을 만드는 트랜잭션이 기업을 읽고 있으면 가져오기가 기다린다. 스냅샷은 이전 값만 담는다. */
+ @Test
+ void 스냅샷을_만드는_동안_가져오기는_기다린다() throws Exception {
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ CountDownLatch read = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ try {
+ Future snapshotting = pool.submit(() -> transactionTemplate.execute(status -> {
+ CompanySnapshot snapshot = companySnapshotFactory.create(companyId);
+ read.countDown();
+ await(release);
+ return snapshot;
+ }));
+ assertThat(read.await(10, TimeUnit.SECONDS)).isTrue();
+
+ Future> importing = pool.submit(() -> transactionTemplate.executeWithoutResult(status ->
+ companyRepository.findForUpdateByCode(companyCode).orElseThrow().setInterviewerBrief("바뀐 요약")));
+ Thread.sleep(500);
+ assertThat(importing.isDone()).as("스냅샷이 끝나기 전에 기업을 고치면 안 된다").isFalse();
+
+ release.countDown();
+ assertThat(snapshotting.get(10, TimeUnit.SECONDS).interviewerBrief()).isEqualTo("멱등커머스는 가상의 기업이다.");
+ importing.get(10, TimeUnit.SECONDS);
+ } finally {
+ release.countDown();
+ pool.shutdownNow();
+ }
+ }
+
+ private static void await(CountDownLatch latch) {
+ try {
+ if (!latch.await(15, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("테스트가 풀어 주지 않았습니다.");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java
index 58e75664..7cfa848d 100644
--- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java
+++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java
@@ -8,6 +8,8 @@
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
+import repit.repit_api_server.domain.company.dto.CompanyBadge;
+import repit.repit_api_server.domain.company.dto.CompanySnapshot;
import repit.repit_api_server.domain.company.service.CompanySnapshotFactory;
import repit.repit_api_server.domain.metadata.service.AnalysisLaunchService;
import repit.repit_api_server.domain.userdata.answer.repository.AnswerRepository;
@@ -24,8 +26,10 @@
import repit.repit_api_server.global.exception.BusinessException;
import repit.repit_api_server.global.response.UserResponse;
+import java.util.List;
import java.util.Optional;
+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.clearInvocations;
@@ -118,4 +122,27 @@ private static AuthUser userWithMajor(String major) {
clearInvocations(analysisLaunchService, questionTailorService);
}
}
+
+ /** 준비 응답의 기업 배지는 면접 스냅샷에서 읽는다. 기업을 고르지 않은 면접은 비어 있다. */
+ @Test
+ void 준비_응답과_재시도_응답에_면접의_기업_배지를_싣는다() {
+ when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(InterviewEntity.builder()
+ .interviewId(INTERVIEW_ID).userId(USER_ID).sessionId("session-1").companyId(12L)
+ .companySnapshot(new CompanySnapshot("12", "샘플커머스", "CONSUMER", List.of(), "요약"))
+ .build()));
+ QuestionTailorEntity tailor = QuestionTailorEntity.builder()
+ .interviewId(INTERVIEW_ID).userId(USER_ID).status(TailorStatus.PENDING).build();
+ when(questionTailorService.requestTailor(any(), any())).thenReturn(tailor);
+ when(questionTailorService.retryPreparation(any(), any())).thenReturn(tailor);
+ AuthUser user = userWithMajor("MAJOR_FRONTEND");
+
+ assertThat(service.prepareInterview(user, INTERVIEW_ID).getCompany())
+ .isEqualTo(new CompanyBadge(12L, "샘플커머스"));
+ assertThat(service.retryPreparation(user, INTERVIEW_ID).getCompany())
+ .isEqualTo(new CompanyBadge(12L, "샘플커머스"));
+
+ when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(InterviewEntity.builder()
+ .interviewId(INTERVIEW_ID).userId(USER_ID).sessionId("session-1").build()));
+ assertThat(service.prepareInterview(user, INTERVIEW_ID).getCompany()).isNull();
+ }
}
diff --git a/src/test/java/repit/repit_api_server/global/error/GlobalExceptionHandlerTest.java b/src/test/java/repit/repit_api_server/global/error/GlobalExceptionHandlerTest.java
index 33b65807..101a04d3 100644
--- a/src/test/java/repit/repit_api_server/global/error/GlobalExceptionHandlerTest.java
+++ b/src/test/java/repit/repit_api_server/global/error/GlobalExceptionHandlerTest.java
@@ -44,6 +44,11 @@ public String business() {
throw BusinessException.notFound("분석 결과를 찾을 수 없습니다.");
}
+ @GetMapping("/business-with-code")
+ public String businessWithCode() {
+ throw new BusinessException("선택한 기업을 찾을 수 없습니다.", HttpStatus.NOT_FOUND, ErrorCodes.COMPANY_UNAVAILABLE);
+ }
+
@GetMapping("/external")
public String external() {
throw new ExternalApiException("인증 실패", HttpStatus.UNAUTHORIZED, new RuntimeException("cause"));
@@ -91,7 +96,17 @@ record EchoRequest(Long interviewId) {
void BusinessException은_지정한_상태코드와_메시지로_응답한다() throws Exception {
mockMvc.perform(get("/business"))
.andExpect(status().isNotFound())
- .andExpect(jsonPath("$.message").value("분석 결과를 찾을 수 없습니다."));
+ .andExpect(jsonPath("$.message").value("분석 결과를 찾을 수 없습니다."))
+ // code는 선택 필드다. 붙이지 않은 오류는 예전 본문 그대로다.
+ .andExpect(jsonPath("$.code").doesNotExist());
+ }
+
+ @Test
+ void 업무_예외에_code가_있으면_message와_함께_내보낸다() throws Exception {
+ mockMvc.perform(get("/business-with-code"))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.message").value("선택한 기업을 찾을 수 없습니다."))
+ .andExpect(jsonPath("$.code").value("COMPANY_UNAVAILABLE"));
}
/**
diff --git a/src/test/resources/config/application.yaml b/src/test/resources/config/application.yaml
index 1420a3db..476aeb7a 100644
--- a/src/test/resources/config/application.yaml
+++ b/src/test/resources/config/application.yaml
@@ -2,3 +2,6 @@
app:
# 테스트는 외부 서버를 부르지 않는다. 기본값이 없어진 콜백 주소를 명시적으로 채워 기동 검사를 통과시킨다.
callback-base-url: https://api.repit.test
+ company:
+ # 기업 테스트는 배포되는 샘플 패키지(company-data/sample)를 쓴다. 샘플을 막는 경우는 테스트가 따로 설정한다.
+ allow-sample: true