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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ public Result importFrom(String location, Set<String> hiddenCodes) throws IOExce
skipped++;
continue;
}
// 검증과 저장이 같은 값을 보도록 앞뒤 공백을 먼저 뺀다. 내용이 같은지는 파일 바이트의 해시로 본다.
pkg = pkg.normalized();
if (pkg.code() != null) {
readCodes.add(pkg.code());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,30 @@ public record CompanyDataPackage(
aliases = aliases == null ? List.of() : aliases;
}

/**
* 글 필드의 앞뒤 공백을 뺀 사본. 검증과 저장이 같은 값을 보게 한다 — 검증은 공백을 뺀 길이로 상한을 보는데(00 §2) 저장이
* 공백 붙은 원래 값을 넣으면, 검증을 통과한 파일이 VARCHAR 길이에 걸려 저장 단계에서 실패한다.
*
* <p>내용은 자르지 않는다. 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<String> stripAll(List<String> values) {
return values == null ? null : values.stream().map(CompanyDataPackage::strip).toList();
}

public record Source(
String sourceKey,
CompanySourceType sourceType,
Expand All @@ -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(
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
* 기업을 목록·신규 면접에 내보낼 수 있는지 정한다. 목록, 면접 생성 스냅샷, 모회사 이름이 같은 조건을 쓴다.
*
* <p>공개(PUBLISHED)이면서 사람이 검수한 기록이 있어야 한다. SAMPLE 패키지는 검수 기록이 없어도 되지만, 허용한 환경
* (개발·스테이징)에서만 내보낸다. 가져오기도 같은 설정으로 SAMPLE을 막지만, 설정을 바꾸기 전에 이미 DB에 들어온 SAMPLE이
* 운영 목록에 남지 않도록 여기서도 본다.
*
* <p>허용 여부는 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -575,10 +578,10 @@ private FeedbackEntity findTarget(FeedbackCallbackRequest request) {
* 기록을 {@code POST /api/interviews/result}로 넘길 뿐 분석 서버를 부르지 않는다.
* 그러니 정상적인 흐름이라면 콜백에 대응하는 행이 이미 있다.
*
* <p>없을 수 있는 경우는 하나다 — 접수는 됐는데 202 응답을 우리가 못 받은 때. 그러면
* 요청은 예외로 끝나 행이 남지 않지만 채점은 그대로 돌아 콜백이 온다. 여기서 받아두지
* <p>없을 수 있는 경우는 둘이다 — 접수는 됐는데 202 응답을 우리가 못 받은 때, 그리고 결과가 202 응답보다 먼저 온 때.
* 앞의 경우 요청은 예외로 끝나 행이 남지 않지만 채점은 그대로 돌아 콜백이 온다. 여기서 받아두지
* 않으면 분석 서버는 두 번 시도한 뒤 결과를 영구 폐기하고, 사용자는 채점이 끝났는데도
* 아무것도 보지 못한다.
* 아무것도 보지 못한다. 세션에 지난 작업의 행만 있어도 이 작업의 행을 새로 만든다.
*/
private FeedbackEntity createFromSession(String sessionId) {
InterviewEntity interview = interviewRepository.findBySessionId(sessionId).orElse(null);
Expand Down
7 changes: 4 additions & 3 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:}
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 보낸_문항_목록이_없으면_근거_있는_항목을_인정하지_않는다() {
Expand Down
Loading