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 70e5af4..1c4ce2a 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 @@ -138,6 +138,8 @@ public Result importFrom(String location, Set hiddenCodes) throws IOExce skipped++; continue; } + // 검증과 저장이 같은 값을 보도록 앞뒤 공백을 먼저 뺀다. 내용이 같은지는 파일 바이트의 해시로 본다. + pkg = pkg.normalized(); if (pkg.code() != null) { readCodes.add(pkg.code()); } diff --git a/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataPackage.java b/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataPackage.java index 33a54c6..ec29b6e 100644 --- a/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataPackage.java +++ b/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataPackage.java @@ -37,6 +37,30 @@ public record CompanyDataPackage( aliases = aliases == null ? List.of() : aliases; } + /** + * 글 필드의 앞뒤 공백을 뺀 사본. 검증과 저장이 같은 값을 보게 한다 — 검증은 공백을 뺀 길이로 상한을 보는데(00 §2) 저장이 + * 공백 붙은 원래 값을 넣으면, 검증을 통과한 파일이 VARCHAR 길이에 걸려 저장 단계에서 실패한다. + * + *

내용은 자르지 않는다. code·parentCode·법인 식별번호·주소는 형식 규칙으로 보는 값이라 그대로 둔다. 빈 항목(null)은 + * 검증이 잡도록 그대로 남긴다. + */ + public CompanyDataPackage normalized() { + return new CompanyDataPackage(schemaVersion, code, strip(displayName), strip(legalName), stripAll(aliases), + parentCode, category, legalSize, strip(legalSizeSource), dartCorpCode, businessRegNo, dataAsOf, + sources == null ? null : sources.stream().map(source -> source == null ? null : source.normalized()).toList(), + facts == null ? null : facts.stream().map(fact -> fact == null ? null : fact.normalized()).toList(), + strip(interviewerBrief), + review == null ? null : new Review(strip(review.reviewedBy()), review.reviewedAt())); + } + + 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(CompanyDataPackage::strip).toList(); + } + public record Source( String sourceKey, CompanySourceType sourceType, @@ -45,6 +69,10 @@ public record Source( String scope, LocalDate publishedAt, LocalDate collectedAt) { + + Source normalized() { + return new Source(strip(sourceKey), sourceType, url, strip(title), strip(scope), publishedAt, collectedAt); + } } public record Fact( @@ -64,6 +92,11 @@ public record Fact( boolean hasSignals() { return !positiveSignals.isEmpty() || !concernSignals.isEmpty(); } + + Fact normalized() { + return new Fact(kind, strip(title), strip(content), strip(sourceKey), stripAll(positiveSignals), + stripAll(concernSignals), signalsOrigin); + } } public record Review(String reviewedBy, LocalDate reviewedAt) { 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 index 81c1c78..7911f1f 100644 --- 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 @@ -1,26 +1,61 @@ package repit.repit_api_server.domain.company.service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; import repit.repit_api_server.domain.company.entity.CompanyEntity; import repit.repit_api_server.domain.company.entity.enums.CompanyStatus; +import java.util.Locale; + /** * 기업을 목록·신규 면접에 내보낼 수 있는지 정한다. 목록, 면접 생성 스냅샷, 모회사 이름이 같은 조건을 쓴다. * *

공개(PUBLISHED)이면서 사람이 검수한 기록이 있어야 한다. SAMPLE 패키지는 검수 기록이 없어도 되지만, 허용한 환경 * (개발·스테이징)에서만 내보낸다. 가져오기도 같은 설정으로 SAMPLE을 막지만, 설정을 바꾸기 전에 이미 DB에 들어온 SAMPLE이 * 운영 목록에 남지 않도록 여기서도 본다. + * + *

허용 여부는 app.company.allow-sample(COMPANY_ALLOW_SAMPLE)로 정한다. 비워 두면 운영 프로필(prod)이 아닐 때만 허용한다 — + * 샘플 위치를 쓰던 개발·스테이징이 설정을 더하지 않아도 그대로 돌고, 운영은 위치를 샘플 폴더로 잘못 적어도 샘플이 공개되지 않는다. */ @Component public class CompanyVisibility { + private static final Logger log = LoggerFactory.getLogger(CompanyVisibility.class); + + // RequiredSettingsCheck.PRODUCTION_PROFILE과 같은 값이다. 이 프로필이 켜져 있으면 운영으로 본다. + static final String PRODUCTION_PROFILE = "prod"; + private final boolean allowSample; - public CompanyVisibility(@Value("${app.company.allow-sample:false}") boolean allowSample) { + @Autowired + public CompanyVisibility(@Value("${app.company.allow-sample:}") String allowSample, Environment environment) { + boolean production = environment.matchesProfiles(PRODUCTION_PROFILE); + this.allowSample = resolve(allowSample, production); + log.info("SAMPLE 기업 허용={} (설정값=\"{}\", 운영 프로필={})", this.allowSample, + allowSample == null ? "" : allowSample.strip(), production); + } + + public CompanyVisibility(boolean allowSample) { this.allowSample = allowSample; } + /** 비어 있으면 운영이 아닐 때만 허용한다. true·false 말고 다른 값은 잘못 적은 것이라 서버를 띄우지 않는다. */ + static boolean resolve(String value, boolean production) { + if (value == null || value.isBlank()) { + return !production; + } + return switch (value.strip().toLowerCase(Locale.ROOT)) { + case "true" -> true; + case "false" -> false; + default -> throw new IllegalStateException( + "COMPANY_ALLOW_SAMPLE(app.company.allow-sample)은 true, false 또는 빈 값이어야 합니다: " + value); + }; + } + public boolean allowsSample() { return allowSample; } 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 f749652..33bfd78 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 @@ -562,7 +562,10 @@ private FeedbackEntity findTarget(FeedbackCallbackRequest request) { FeedbackEntity bySession = feedbackRepository.findTopBySessionIdOrderByCreatedAtDesc(request.getSessionId()).orElse(null); - if (bySession != null) { + // 다른 작업의 행에는 쓰지 않는다. 재채점 결과가 접수 응답보다 먼저 오면 세션의 최신 행은 지난 작업(FAILED 등)의 것이다. + // 거기에 쓰면 곧이어 접수 응답이 이 작업의 PENDING 행을 새로 만들어 결과를 가린다. 그때는 이 작업의 행을 새로 만들고, + // 늦게 온 접수 응답은 jobId로 그 행을 찾아 덧붙이지 않는다(saveAccepted). + if (bySession != null && (request.getJobId() == null || bySession.getJobId() == null)) { return bySession; } return createFromSession(request.getSessionId()); @@ -575,10 +578,10 @@ private FeedbackEntity findTarget(FeedbackCallbackRequest request) { * 기록을 {@code POST /api/interviews/result}로 넘길 뿐 분석 서버를 부르지 않는다. * 그러니 정상적인 흐름이라면 콜백에 대응하는 행이 이미 있다. * - *

없을 수 있는 경우는 하나다 — 접수는 됐는데 202 응답을 우리가 못 받은 때. 그러면 - * 요청은 예외로 끝나 행이 남지 않지만 채점은 그대로 돌아 콜백이 온다. 여기서 받아두지 + *

없을 수 있는 경우는 둘이다 — 접수는 됐는데 202 응답을 우리가 못 받은 때, 그리고 결과가 202 응답보다 먼저 온 때. + * 앞의 경우 요청은 예외로 끝나 행이 남지 않지만 채점은 그대로 돌아 콜백이 온다. 여기서 받아두지 * 않으면 분석 서버는 두 번 시도한 뒤 결과를 영구 폐기하고, 사용자는 채점이 끝났는데도 - * 아무것도 보지 못한다. + * 아무것도 보지 못한다. 세션에 지난 작업의 행만 있어도 이 작업의 행을 새로 만든다. */ private FeedbackEntity createFromSession(String sessionId) { InterviewEntity interview = interviewRepository.findBySessionId(sessionId).orElse(null); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2d5d6e4..f0541e0 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -156,9 +156,10 @@ app: # 운영: 실제 데이터가 준비되면 classpath:company-data/prod/*.json # 파일이 빠졌다고 기업을 숨기지 않는다. 내릴 기업은 아래 hidden-codes에 넣는다. data-location: ${COMPANY_DATA_LOCATION:} - # 출처가 모두 SAMPLE인 개발용 패키지를 가져오고 목록·면접 생성에 내보낼지. 개발·스테이징만 true로 켠다. - # 운영은 기본값(false) 그대로 둔다 — 위치를 샘플 폴더로 잘못 적어도 샘플이 공개되지 않는다. - allow-sample: ${COMPANY_ALLOW_SAMPLE:false} + # 출처가 모두 SAMPLE인 개발용 패키지를 가져오고 목록·면접 생성에 내보낼지. true / false / 빈 값. + # 비워 두면 운영 프로필(prod)이 아닐 때만 허용한다 — 개발·스테이징은 따로 켜지 않아도 되고, 운영은 위치를 샘플 폴더로 + # 잘못 적어도 샘플이 공개되지 않는다. 운영에서 prod 프로필을 켜지 않는다면 false를 직접 넣는다. + allow-sample: ${COMPANY_ALLOW_SAMPLE:} # 목록·신규 선택에서 내릴 기업의 code. 쉼표로 나눈다. 기동할 때 HIDDEN으로 바꾸고, 이미 만든 면접의 스냅샷은 그대로 둔다. # 목록에서 빼면 다음 기동 때 다시 공개한다. 법적 사유의 긴급 삭제는 이 설정으로 대신하지 않는다. hidden-codes: ${COMPANY_HIDDEN_CODES:} diff --git a/src/main/resources/db/migration/V38__company_sample_requires_source.sql b/src/main/resources/db/migration/V38__company_sample_requires_source.sql new file mode 100644 index 0000000..0ea8349 --- /dev/null +++ b/src/main/resources/db/migration/V38__company_sample_requires_source.sql @@ -0,0 +1,9 @@ +-- V35가 출처가 하나도 없는 기업까지 SAMPLE로 표시했다(NOT EXISTS가 빈 집합에서 참이다). 가져오기의 판정 +-- (CompanyDataImporter.isSample: 출처가 하나 이상이고 모두 SAMPLE)과 맞춰 다시 계산한다. V35는 이미 적용된 곳이 있어 +-- 고치지 않고 여기서 바로잡는다. +UPDATE company c + SET sample = EXISTS (SELECT 1 FROM company_source s WHERE s.company_id = c.company_id) + AND NOT EXISTS (SELECT 1 + FROM company_source s + WHERE s.company_id = c.company_id + AND s.source_type <> 'SAMPLE'); 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 360b2ae..1eeb704 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 @@ -26,9 +26,11 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.time.LocalDate; import java.util.Comparator; import java.util.List; import java.util.Set; @@ -325,6 +327,60 @@ private CompanyEntity parentOf(String code) { assertThat(parentOf("sample-games")).isNull(); } + /** + * 길이 상한은 앞뒤 공백을 뺀 값으로 본다. 저장도 공백을 뺀 값으로 해야, 공백 때문에 VARCHAR 길이를 넘는 파일이 검증은 + * 통과하고 저장에서 실패하는 일이 없다. + */ + @Test + void 앞뒤_공백이_붙은_값은_공백을_빼고_저장한다() throws IOException { + String padding = " ".repeat(30); + rewrite("sample-commerce.json", "\"displayName\": \"샘플커머스\"", + "\"displayName\": \"" + padding + "샘플커머스" + padding + "\""); + rewrite("sample-commerce.json", "\"title\": \"샘플마켓\"", "\"title\": \"샘플마켓" + padding + "\""); + rewrite("sample-commerce.json", "\"sourceKey\": \"about\",\n \"sourceType\"", + "\"sourceKey\": \" about \",\n \"sourceType\""); + + assertThat(importer.importFrom(location())).isEqualTo(new Result(3, 0, 0, 0, 0)); + entityManager.flush(); + entityManager.clear(); + + CompanyEntity commerce = company("sample-commerce"); + assertThat(commerce.getDisplayName()).isEqualTo("샘플커머스"); + assertThat(companyFactRepository.findAllByCompanyIdOrderBySortOrder(commerce.getCompanyId()).getFirst().getTitle()) + .isEqualTo("샘플마켓"); + // 출처 키도 같은 규칙으로 맞춰 fact가 그 출처를 그대로 가리킨다. + assertThat(companySourceRepository.findAll()).filteredOn(source -> source.getCompanyId().equals(commerce.getCompanyId())) + .singleElement().extracting(CompanySourceEntity::getSourceKey).isEqualTo("about"); + } + + /** V38은 출처가 없는 기업을 SAMPLE에서 뺀다. 출처가 모두 SAMPLE인 기업만 SAMPLE이다(가져오기 판정과 같다). */ + @Test + void V38은_출처가_있고_모두_SAMPLE인_기업만_SAMPLE로_둔다() throws IOException { + importer.importFrom(location()); + CompanyEntity orphan = companyRepository.save(CompanyEntity.builder() + .code("no-source") + .displayName("출처없음") + .legalName("출처없음 주식회사") + .aliases(List.of()) + .category(CompanyCategory.SECURITY) + .status(CompanyStatus.PUBLISHED) + .interviewerBrief("출처가 없는 기업이다.") + .dataAsOf(LocalDate.parse("2026-10-15")) + .contentHash("0".repeat(64)) + .build()); + entityManager.flush(); + // V35가 남긴 상태를 만든다. + entityManager.createNativeQuery("update company set sample = true").executeUpdate(); + + String sql = new ClassPathResource("db/migration/V38__company_sample_requires_source.sql") + .getContentAsString(StandardCharsets.UTF_8); + entityManager.createNativeQuery(sql).executeUpdate(); + entityManager.clear(); + + assertThat(companyRepository.findById(orphan.getCompanyId()).orElseThrow().isSample()).isFalse(); + assertThat(company("sample-commerce").isSample()).isTrue(); + } + @Test void 샘플_패키지는_SAMPLE로_표시하고_정렬은_분류_이름_id_순이다() throws IOException { importer.importFrom(location()); diff --git a/src/test/java/repit/repit_api_server/domain/company/service/CompanyVisibilityTest.java b/src/test/java/repit/repit_api_server/domain/company/service/CompanyVisibilityTest.java new file mode 100644 index 0000000..b5e126b --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/company/service/CompanyVisibilityTest.java @@ -0,0 +1,39 @@ +package repit.repit_api_server.domain.company.service; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** SAMPLE 허용 설정. 비워 두면 운영 프로필이 아닐 때만 허용한다. */ +class CompanyVisibilityTest { + + @Test + void 비워_두면_운영이_아닐_때만_허용한다() { + assertThat(CompanyVisibility.resolve(null, false)).isTrue(); + assertThat(CompanyVisibility.resolve(" ", false)).isTrue(); + assertThat(CompanyVisibility.resolve("", true)).isFalse(); + } + + @Test + void 값을_넣으면_프로필과_상관없이_그_값을_쓴다() { + assertThat(CompanyVisibility.resolve("true", true)).isTrue(); + assertThat(CompanyVisibility.resolve(" FALSE ", false)).isFalse(); + } + + @Test + void true_false가_아닌_값은_서버를_띄우지_않는다() { + assertThatThrownBy(() -> CompanyVisibility.resolve("yes", false)).isInstanceOf(IllegalStateException.class); + } + + @Test + void 운영_프로필이_켜져_있으면_기본으로_막는다() { + MockEnvironment production = new MockEnvironment(); + production.setActiveProfiles("prod"); + + assertThat(new CompanyVisibility("", production).allowsSample()).isFalse(); + assertThat(new CompanyVisibility("", new MockEnvironment()).allowsSample()).isTrue(); + assertThat(new CompanyVisibility("true", production).allowsSample()).isTrue(); + } +} 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 8203228..87f2da5 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 @@ -386,6 +386,53 @@ private static JsonNode json(String text) { assertThat(saved.getRequestedQuestionIds()).containsExactly(answeredQuestionId); } + /** + * 재채점 결과가 접수 응답보다 먼저 와도 지난 작업의 행에 쓰지 않는다. 쓰면 곧이어 접수 응답이 만드는 PENDING 행이 최신이 + * 되어 결과를 가린다. 이 작업의 행을 새로 만들고, 접수 응답은 그 행을 찾아 덧붙이지 않는다. + */ + @Test + void 재채점_결과가_접수_응답보다_먼저_와도_지난_작업의_행에_쓰지_않는다() { + InterviewEntity interview = givenFinishedInterview(SNAPSHOT); + String oldJobId = "old-" + UUID.randomUUID(); + Long oldFeedbackId = feedbackRepository.save(FeedbackEntity.builder() + .interviewId(interview.getInterviewId()) + .userId(USER_ID) + .sessionId(sessionId) + .jobId(oldJobId) + .status(FeedbackStatus.FAILED) + .errorMessage("지난 채점 실패") + .build()).getFeedbackId(); + JsonNode companyFit = json(""" + { + "comment": "오너십 관점에서 원인을 끝까지 따라간 설명이 보였습니다.", + "matchedFocus": [ { "factId": "23", "evidence": "원인을 직접 추적했습니다.", "questionIds": ["%s"] } ] + }""".formatted(answeredQuestionId)); + 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 old = feedbackRepository.findById(oldFeedbackId).orElseThrow(); + assertThat(old.getStatus()).isEqualTo(FeedbackStatus.FAILED); + assertThat(old.getJobId()).isEqualTo(oldJobId); + assertThat(old.getTotalScore()).isNull(); + + FeedbackEntity latest = feedbackRepository.findTopByInterviewIdOrderByCreatedAtDesc(interview.getInterviewId()) + .orElseThrow(); + assertThat(latest.getJobId()).isEqualTo(jobId); + assertThat(latest.getStatus()).isEqualTo(FeedbackStatus.SUCCEEDED); + assertThat(latest.getTotalScore()).isEqualTo(72); + assertThat(latest.getRequestedQuestionIds()).containsExactly(answeredQuestionId); + assertThat(latest.getCompanyFit().matchedFocus()).hasSize(1); + assertThat(feedbackRepository.findAll()).filteredOn(row -> row.getInterviewId().equals(interview.getInterviewId())) + .hasSize(2); + assertThat(feedbackService.getFeedback(USER_ID, interview.getInterviewId()).getTotalScore()).isEqualTo(72); + } + /** 문항 목록을 남기기 전에 보낸 요청의 콜백은 어느 문항도 근거로 인정하지 않는다. 총평만 있는 코멘트는 둔다. */ @Test void 보낸_문항_목록이_없으면_근거_있는_항목을_인정하지_않는다() {