From ebf41807abf13e08c146d7e51e15bd0f3ff303d7 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Mon, 19 Oct 2026 13:06:58 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B8=B0=EC=97=85=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EC=85=8B=20=EA=B3=84=EC=95=BD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81(=EC=9A=94=EC=B2=AD=ED=82=A4=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80,=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=9E=A0?= =?UTF-8?q?=EA=B8=88,=20companyFit=20=EA=B7=BC=EA=B1=B0=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 면접 생성: 선택 Idempotency-Key(UUID) 헤더. 같은 사용자·키·설정이면 처음 만든 면접을 돌려주고, 설정이 다르면 409 IDEMPOTENCY_KEY_REUSED. 면접·면접관·키를 한 트랜잭션에 저장한다. 헤더·companyId 형식 오류는 422 INVALID_INTERVIEW_REQUEST. - 스냅샷: 기업 행을 FOR SHARE로 읽고 가져오기는 FOR UPDATE로 고쳐 섞인 스냅샷을 막는다. 없는·숨긴 기업은 404 COMPANY_UNAVAILABLE, 실행 입력을 만들 수 없으면 500 대신 503 COMPANY_CONTEXT_UNAVAILABLE. - 가져오기: 파일이 빠져도 숨기지 않고 app.company.hidden-codes로만 숨긴다. app.company.allow-sample(기본 false)이 꺼진 환경은 SAMPLE을 가져오지도 내보내지도 않는다. 자기 참조·순환 모회사는 관계만 빼고 반영한다. 로그에서 원문 값을 뺐다. - 목록: 검수 기록이 있거나 허용된 SAMPLE만 내보내고, 이름이 같으면 companyId 순. - companyFit: 스냅샷 factId와 그 면접의 답변 있는 문항만 근거로 남기고 kind·focus는 스냅샷에서 채운다. 항목이 모두 빠지면 null. 기업 미선택 면접은 조회에서도 null. - 오류 응답에 선택 code 필드, 준비 응답에 company 배지 추가. - V35: company.sample, fact의 자기 기업 출처 복합 FK, interview_create_request. Co-Authored-By: Claude Opus 5.5 --- .../domain/company/entity/CompanyEntity.java | 7 +- .../company/entity/enums/CompanyStatus.java | 6 +- .../company/importer/CompanyDataImporter.java | 172 +++++-- .../importer/CompanyDataValidator.java | 31 +- .../company/repository/CompanyRepository.java | 17 +- .../company/service/CompanyQueryService.java | 18 +- .../service/CompanySnapshotFactory.java | 138 ++++-- .../company/service/CompanyVisibility.java | 38 ++ .../dto/response/CompanyFitResponse.java | 5 +- .../dto/response/FeedbackResponse.java | 8 +- .../feedback/service/CompanyFitReader.java | 186 +++++++- .../feedback/service/FeedbackService.java | 20 +- .../controller/InterviewController.java | 10 +- .../dto/request/CreateInterviewRequest.java | 3 +- .../response/InterviewPrepareResponse.java | 9 +- .../entity/InterviewCreateRequestEntity.java | 43 ++ .../InterviewCreateRequestRepository.java | 12 + .../service/InterviewCreateFingerprint.java | 49 ++ .../service/InterviewCreateService.java | 117 +++++ .../interview/service/InterviewService.java | 13 +- .../dto/response/QuestionTailorResponse.java | 9 + .../service/QuestionTailorService.java | 9 +- .../global/error/ErrorCodes.java | 21 + .../global/error/ErrorResponse.java | 15 +- .../global/error/GlobalExceptionHandler.java | 5 +- .../global/exception/BusinessException.java | 8 + src/main/resources/application.yaml | 7 + .../V35__company_dataset_contract.sql | 28 ++ .../CompanyDataImporterIsolationTest.java | 15 +- .../importer/CompanyDataImporterTest.java | 114 ++++- .../importer/CompanyDataValidatorTest.java | 23 +- .../service/CompanyQueryServiceTest.java | 36 +- .../service/CompanySnapshotFactoryTest.java | 99 +++- .../service/CompanyFitReaderTest.java | 172 +++++++ .../service/FeedbackServiceCompanyTest.java | 112 ++++- .../InterviewControllerCreateTest.java | 194 ++++++++ .../InterviewCreateIdempotencyTest.java | 435 ++++++++++++++++++ .../service/InterviewServicePrepareTest.java | 27 ++ .../error/GlobalExceptionHandlerTest.java | 17 +- src/test/resources/config/application.yaml | 3 + 40 files changed, 2095 insertions(+), 156 deletions(-) create mode 100644 src/main/java/repit/repit_api_server/domain/company/service/CompanyVisibility.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewCreateRequestEntity.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewCreateRequestRepository.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateFingerprint.java create mode 100644 src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateService.java create mode 100644 src/main/java/repit/repit_api_server/global/error/ErrorCodes.java create mode 100644 src/main/resources/db/migration/V35__company_dataset_contract.sql create mode 100644 src/test/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReaderTest.java create mode 100644 src/test/java/repit/repit_api_server/domain/userdata/interview/controller/InterviewControllerCreateTest.java create mode 100644 src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewCreateIdempotencyTest.java 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/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}와 같은 이유로 요청 본문에서는 모양을 가리지 않고 받아 여기서 푼다. 타입을 정해 - * 받으면 모양이 하나만 어긋나도 본문 전체를 읽지 못해 채점 결과까지 잃는다. 기업 관점 코멘트는 없어도 피드백이 - * 성립하는 부가 정보라, 읽지 못하면 비워 두고 어긋난 사실을 로그로 남긴다. + *

+ * + *

로그에는 면접 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..aa8699a6 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; @@ -599,8 +600,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, + counts.answeredQuestionIds(), interviewId)); feedback.setStatus(FeedbackStatus.SUCCEEDED); feedback.setErrorStatusCode(null); feedback.setErrorMessage(null); @@ -815,12 +820,15 @@ private RecordedCounts recordedCounts(Long interviewId) { Map personaByQuestion = new LinkedHashMap<>(); Map questionCountByPersona = new LinkedHashMap<>(); Map answeredCountByPersona = new LinkedHashMap<>(); + Set answeredQuestionIds = new LinkedHashSet<>(); int answeredCount = 0; for (QuestionEntity question : questions) { boolean hasAnswer = answered.contains(question.getQuestionId()); if (hasAnswer) { answeredCount++; + // 채점 요청에 실은 문항 id와 같은 글자다(toQuestion·toMultiQuestion). + answeredQuestionIds.add(String.valueOf(question.getQuestionId())); } // 꼬리질문은 부모의 면접관을 물려받는다. 부모는 늘 먼저 저장돼 한 번 훑으면 채워진다. @@ -840,14 +848,18 @@ private RecordedCounts recordedCounts(Long interviewId) { } return new RecordedCounts(questions.size(), answeredCount, personaByQuestion, - questionCountByPersona, answeredCountByPersona); + questionCountByPersona, answeredCountByPersona, answeredQuestionIds); } /** 기록으로 센 수. 분석 서버가 보낸 수와 어긋나면 기록 쪽을 쓰고 그 사실을 남긴다. */ private record RecordedCounts(int questionCount, int answeredCount, Map personaByQuestion, Map questionCountByPersona, - Map answeredCountByPersona) { + Map answeredCountByPersona, + // 기업 관점 코멘트가 근거로 댈 수 있는 문항. 채점 요청과 같은 규칙으로 면접 기록에서 다시 뽑은, 내용 있는 + // 답변이 붙은 문항이다. 채팅 서버가 넘긴 기록은 다시 쓰지 않아(claimResultSave) 요청 때와 같다. 요청 뒤 + // 답변 API로 답변이 더해진 문항은 여기 들어온다 — 그 작업에 보낸 목록을 따로 저장하지는 않는다. + Set answeredQuestionIds) { Integer questionCountOr(Integer received, String label, Long interviewId) { return reconcile(questionCount, received, label, interviewId); 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/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}는 선택이다. 없으면 예전처럼 요청마다 새 면접을 만든다. 있으면 (사용자, 키)마다 면접 하나다. + *

+ * 키 저장소를 읽지 못하면 그대로 실패한다. 키 보장을 건너뛰고 새 면접을 만들지 않는다. + */ +@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/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..83b5cbdb 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 @@ -85,6 +85,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,9 +129,46 @@ 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 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 FeedbackSoloRequest requested(CompanySnapshot snapshot) { InterviewEntity interview = givenFinishedInterview(snapshot); when(aiServerClient.requestSoloFeedback(any())) @@ -182,11 +222,11 @@ private static JsonNode json(String text) { "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 +234,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 +257,72 @@ private static JsonNode json(String text) { } } + /** + * 근거는 이 면접에서 답변이 있는 문항이어야 한다. 답하지 않은 문항, 다른 면접의 문항만 댄 항목은 빼고, kind·focus는 + * 모델이 쓴 값 대신 스냅샷 fact에서 채운다. 기본 피드백은 그대로 저장한다. + */ + @Test + void 이_면접의_답변_있는_문항만_근거로_남긴다() { + givenFinishedInterview(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 항목이_모두_빠지면_기업_관점_코멘트는_비운다() { + givenFinishedInterview(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(); + } + + /** 기업을 고르지 않은 면접에 코멘트가 와도 잇지 않는다. */ + @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 From 47e13bbbaf24e617375da28bb3c6f4112b54af41 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Mon, 19 Oct 2026 13:27:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EA=B8=B0=EC=97=85=20=EA=B4=80?= =?UTF-8?q?=EC=A0=90=20=EC=BD=94=EB=A9=98=ED=8A=B8=EC=9D=98=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=EB=A5=BC=20=EC=B1=84=EC=A0=90=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=95=8C=20=EB=B3=B4=EB=82=B8=20=EB=AC=B8=ED=95=AD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 콜백 때 면접 기록에서 답변 있는 문항을 다시 뽑으면, 요청 뒤 답변 API로 더해진 답변의 문항까지 근거로 통과했다. 요청에 실은 답변 있는 문항 id를 남기고 그 목록만 인정한다. - 접수 행(feedback.requested_question_ids)에 작업별 목록을 넣는다. 콜백이 접수 응답보다 먼저 오는 경우를 위해 보내기 직전 interview.last_feedback_question_ids에도 남긴다. - 둘 다 없는 콜백(목록을 남기기 전에 보낸 요청)은 어느 문항도 근거로 인정하지 않는다. - V36 마이그레이션. Co-Authored-By: Claude Opus 5.5 --- .../feedback/entity/FeedbackEntity.java | 6 + .../repository/FeedbackRepository.java | 18 +++ .../feedback/service/FeedbackService.java | 101 ++++++++++++---- .../repository/InterviewRepository.java | 23 ++++ .../V36__feedback_requested_question_ids.sql | 8 ++ .../service/FeedbackServiceCompanyTest.java | 108 ++++++++++++++++-- 6 files changed, 234 insertions(+), 30 deletions(-) create mode 100644 src/main/resources/db/migration/V36__feedback_requested_question_ids.sql 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/FeedbackService.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java index aa8699a6..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 @@ -36,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; @@ -153,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); + } + /** * 접수 사실을 남긴다. * @@ -174,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; } @@ -188,6 +222,7 @@ private void saveAccepted(InterviewEntity interview, String jobId) { .sessionId(interview.getSessionId()) .jobId(jobId) .status(FeedbackStatus.PENDING) + .requestedQuestionIds(requestedQuestionIds) .build()); } catch (DataIntegrityViolationException e) { // 확인과 저장 사이에 콜백이 끼어든 경우다. 결과는 콜백이 이미 남겼으니 접수만 넘어간다. @@ -252,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() @@ -285,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()); @@ -508,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()); } @@ -570,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); @@ -600,12 +633,12 @@ private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Resul feedback.setQuestionCount(counts.questionCountOr(overall.getQuestionCount(), "면접 전체 문항 수", interviewId)); } - // 기업 관점 코멘트. 그 면접의 스냅샷과 답변 있는 문항에 맞지 않으면 비워 두고, 피드백은 그대로 저장한다. + // 기업 관점 코멘트. 그 면접의 스냅샷과 이 작업에 보낸 답변 있는 문항에 맞지 않으면 비워 두고, 피드백은 그대로 저장한다. CompanySnapshot snapshot = interviewRepository.findById(interviewId) .map(InterviewEntity::getCompanySnapshot) .orElse(null); feedback.setCompanyFit(CompanyFitReader.read(result.getCompanyFit(), snapshot, - counts.answeredQuestionIds(), interviewId)); + requestedQuestionIds(feedback, callbackJobId), interviewId)); feedback.setStatus(FeedbackStatus.SUCCEEDED); feedback.setErrorStatusCode(null); feedback.setErrorMessage(null); @@ -618,6 +651,35 @@ private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Resul feedbackItemRepository.saveAll(itemRows); } + /** + * 이 콜백의 기업 관점 코멘트가 근거로 댈 수 있는 문항. 콜백 시점의 면접 기록에서 다시 뽑지 않는다 — 요청 뒤 답변 API로 + * 더해진 답변의 문항이 근거로 통과한다. + * + *

    + *
  1. 이 작업의 접수 행이면 접수할 때 남긴 목록이다.
  2. + *
  3. 콜백이 접수 응답보다 먼저 와 행에 이 작업의 목록이 없으면, 보내기 직전에 면접에 남긴 목록이다.
  4. + *
  5. 둘 다 없으면(목록을 남기기 전에 보낸 요청) 어느 문항도 근거로 인정하지 않는다.
  6. + *
+ */ + 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() @@ -820,15 +882,12 @@ private RecordedCounts recordedCounts(Long interviewId) { Map personaByQuestion = new LinkedHashMap<>(); Map questionCountByPersona = new LinkedHashMap<>(); Map answeredCountByPersona = new LinkedHashMap<>(); - Set answeredQuestionIds = new LinkedHashSet<>(); int answeredCount = 0; for (QuestionEntity question : questions) { boolean hasAnswer = answered.contains(question.getQuestionId()); if (hasAnswer) { answeredCount++; - // 채점 요청에 실은 문항 id와 같은 글자다(toQuestion·toMultiQuestion). - answeredQuestionIds.add(String.valueOf(question.getQuestionId())); } // 꼬리질문은 부모의 면접관을 물려받는다. 부모는 늘 먼저 저장돼 한 번 훑으면 채워진다. @@ -848,18 +907,14 @@ private RecordedCounts recordedCounts(Long interviewId) { } return new RecordedCounts(questions.size(), answeredCount, personaByQuestion, - questionCountByPersona, answeredCountByPersona, answeredQuestionIds); + questionCountByPersona, answeredCountByPersona); } /** 기록으로 센 수. 분석 서버가 보낸 수와 어긋나면 기록 쪽을 쓰고 그 사실을 남긴다. */ private record RecordedCounts(int questionCount, int answeredCount, Map personaByQuestion, Map questionCountByPersona, - Map answeredCountByPersona, - // 기업 관점 코멘트가 근거로 댈 수 있는 문항. 채점 요청과 같은 규칙으로 면접 기록에서 다시 뽑은, 내용 있는 - // 답변이 붙은 문항이다. 채팅 서버가 넘긴 기록은 다시 쓰지 않아(claimResultSave) 요청 때와 같다. 요청 뒤 - // 답변 API로 답변이 더해진 문항은 여기 들어온다 — 그 작업에 보낸 목록을 따로 저장하지는 않는다. - Set answeredQuestionIds) { + Map answeredCountByPersona) { Integer questionCountOr(Integer received, String label, Long interviewId) { return reconcile(questionCount, received, label, interviewId); 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/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/userdata/feedback/service/FeedbackServiceCompanyTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceCompanyTest.java index 83b5cbdb..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; @@ -169,23 +170,33 @@ private String otherInterviewQuestionId() { return String.valueOf(question.getQuestionId()); } - private FeedbackSoloRequest requested(CompanySnapshot snapshot) { + /** 끝난 면접의 채점을 요청해 접수까지 마친다. 기업 관점 코멘트의 근거는 이때 보낸 문항으로 정해진다. */ + 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(); @@ -215,7 +226,7 @@ private static JsonNode json(String text) { @Test void 콜백의_기업_관점_코멘트를_저장하고_조회에_싣는다() { - InterviewEntity interview = givenFinishedInterview(SNAPSHOT); + InterviewEntity interview = givenRequested(SNAPSHOT); FeedbackEntity saved = callback(json(""" { @@ -263,7 +274,7 @@ private static JsonNode json(String text) { */ @Test void 이_면접의_답변_있는_문항만_근거로_남긴다() { - givenFinishedInterview(SNAPSHOT); + givenRequested(SNAPSHOT); String otherQuestionId = otherInterviewQuestionId(); FeedbackEntity saved = callback(json(""" @@ -293,7 +304,7 @@ private static JsonNode json(String text) { /** 항목이 있었는데 모두 근거가 맞지 않으면 총평만 살리지 않는다. 기본 피드백은 저장한다. */ @Test void 항목이_모두_빠지면_기업_관점_코멘트는_비운다() { - givenFinishedInterview(SNAPSHOT); + givenRequested(SNAPSHOT); FeedbackEntity saved = callback(json(""" { @@ -310,6 +321,89 @@ private static JsonNode json(String text) { 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 기업이_없는_면접에_온_기업_관점_코멘트는_버린다() {