diff --git a/src/main/java/repit/repit_api_server/domain/company/CompanyLimits.java b/src/main/java/repit/repit_api_server/domain/company/CompanyLimits.java new file mode 100644 index 00000000..3210c029 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/CompanyLimits.java @@ -0,0 +1,24 @@ +package repit.repit_api_server.domain.company; + +/** + * 분석 서버 기업 계약(00 §6-1)의 길이·개수 상한. 분석 서버가 같은 규칙으로 422를 내므로, + * 패키지를 가져올 때와 스냅샷을 만들 때 같은 값으로 막는다. + */ +public final class CompanyLimits { + + public static final int DISPLAY_NAME = 30; + public static final int FACT_TITLE = 20; + public static final int FACT_CONTENT = 200; + public static final int SIGNALS_PER_FACT = 3; + public static final int SIGNAL = 100; + public static final int INTERVIEWER_BRIEF = 600; + public static final int FACTS = 20; + + private CompanyLimits() { + } + + /** 분석 서버(Python)가 세는 글자 수와 맞춘다. String.length()는 이모지 하나를 둘로 센다. */ + public static int length(String value) { + return value.codePointCount(0, value.length()); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/company/controller/CompanyController.java b/src/main/java/repit/repit_api_server/domain/company/controller/CompanyController.java new file mode 100644 index 00000000..8f87e098 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/controller/CompanyController.java @@ -0,0 +1,29 @@ +package repit.repit_api_server.domain.company.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import repit.repit_api_server.domain.company.dto.CompanySummaryResponse; +import repit.repit_api_server.domain.company.entity.enums.CompanyCategory; +import repit.repit_api_server.domain.company.service.CompanyQueryService; +import repit.repit_api_server.global.common.ApiResponse; + +import java.util.List; + +@RestController +@RequestMapping("/api/companies") +@RequiredArgsConstructor +public class CompanyController { + + private final CompanyQueryService companyQueryService; + + // 공개 기업 목록. 공개 기업이 없으면 빈 목록이고, 웹은 그때 기업 선택 섹션을 숨긴다. + @GetMapping + public ApiResponse> getCompanies( + @RequestParam(required = false) String q, + @RequestParam(required = false) CompanyCategory category) { + return ApiResponse.success(companyQueryService.search(q, category)); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/company/dto/ChatCompany.java b/src/main/java/repit/repit_api_server/domain/company/dto/ChatCompany.java new file mode 100644 index 00000000..0f702cde --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/dto/ChatCompany.java @@ -0,0 +1,5 @@ +package repit.repit_api_server.domain.company.dto; + +/** 채팅 서버 준비 요청의 company(00 §6-3). 꼬리질문은 이 요약만으로 기업을 반영한다. */ +public record ChatCompany(String displayName, String interviewerBrief) { +} diff --git a/src/main/java/repit/repit_api_server/domain/company/dto/CompanyBadge.java b/src/main/java/repit/repit_api_server/domain/company/dto/CompanyBadge.java new file mode 100644 index 00000000..adbaecfe --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/dto/CompanyBadge.java @@ -0,0 +1,10 @@ +package repit.repit_api_server.domain.company.dto; + +/** 면접·피드백 응답에 붙는 기업 표시. 기업을 고르지 않은 면접은 null이다. */ +public record CompanyBadge(Long companyId, String displayName) { + + // id는 면접의 company_id 컬럼에서 받는다. 스냅샷의 문자열 id를 다시 숫자로 바꾸지 않는다. + public static CompanyBadge of(Long companyId, CompanySnapshot snapshot) { + return snapshot == null ? null : new CompanyBadge(companyId, snapshot.displayName()); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/company/dto/CompanySnapshot.java b/src/main/java/repit/repit_api_server/domain/company/dto/CompanySnapshot.java new file mode 100644 index 00000000..72e83332 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/dto/CompanySnapshot.java @@ -0,0 +1,25 @@ +package repit.repit_api_server.domain.company.dto; + +import java.util.List; + +/** + * 면접을 만들 때 복사해 두는 기업 정보. interview.company_snapshot에 이 모양 그대로 저장되고, + * 분석 서버 요청의 company(00 §6-1)에도 그대로 실린다 — 변환 코드를 따로 두지 않는다. + * + *

규모·별칭·출처·signalsOrigin은 프롬프트에 필요 없어 넣지 않는다. 기업 정보가 나중에 바뀌어도 + * 지난 면접과 피드백은 이 스냅샷 기준으로 남는다. + */ +public record CompanySnapshot( + String companyId, String displayName, String category, + List facts, String interviewerBrief) { + + // 신호는 TALENT에만 있다. 나머지는 빈 목록이고, 분석 서버는 그것을 "신호 없음"으로 받는다. + public record Fact(String factId, String kind, String title, String content, + List positiveSignals, List concernSignals) { + } + + // 채팅 서버는 실시간 경로라 요약만 받는다. + public ChatCompany toChat() { + return new ChatCompany(displayName, interviewerBrief); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/company/dto/CompanySummaryResponse.java b/src/main/java/repit/repit_api_server/domain/company/dto/CompanySummaryResponse.java new file mode 100644 index 00000000..96b634de --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/dto/CompanySummaryResponse.java @@ -0,0 +1,21 @@ +package repit.repit_api_server.domain.company.dto; + +import repit.repit_api_server.domain.company.entity.enums.CompanyCategory; +import repit.repit_api_server.domain.company.entity.enums.LegalSize; + +import java.time.LocalDate; +import java.util.List; + +/** GET /api/companies 한 건. */ +public record CompanySummaryResponse( + Long companyId, + String displayName, + String legalName, + String parentDisplayName, + CompanyCategory category, + // 미확인이면 null이다. + LegalSize legalSize, + // TALENT fact의 title. 표시 순서대로 최대 3개. + List talentKeywords, + LocalDate dataAsOf) { +} 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 new file mode 100644 index 00000000..dd704fbf --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/entity/CompanyEntity.java @@ -0,0 +1,99 @@ +package repit.repit_api_server.domain.company.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.type.SqlTypes; +import repit.repit_api_server.domain.company.entity.enums.CompanyCategory; +import repit.repit_api_server.domain.company.entity.enums.CompanyStatus; +import repit.repit_api_server.domain.company.entity.enums.LegalSize; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 면접에서 고를 수 있는 기업. 기동할 때 기업 데이터 패키지에서 가져온다 — {@code CompanyDataImporter} 참고. + * + *

행은 지우지 않는다. 면접이 company_id로 가리키고 있어, 패키지에서 빠진 기업은 HIDDEN으로만 바꾼다. + */ +@Entity +@Table(name = "company") +@Getter +@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class CompanyEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "company_id") + private Long companyId; + + // 패키지의 영구 키. 가져올 때 이 값으로 기존 행을 찾는다. + @Column(nullable = false, unique = true, length = 40) + private String code; + + @Column(nullable = false, length = 30) + private String displayName; + + @Column(nullable = false, length = 100) + private String legalName; + + // 검색용 다른 이름. + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private List aliases; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_company_id") + private CompanyEntity parent; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private CompanyCategory category; + + // 비어 있으면 미확인이다. + @Enumerated(EnumType.STRING) + @Column(length = 10) + private LegalSize legalSize; + + @Column(columnDefinition = "TEXT") + private String legalSizeSource; + + @Column(length = 8, unique = true) + private String dartCorpCode; + + @Column(length = 10, unique = true) + private String businessRegNo; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private CompanyStatus status; + + // 채팅 서버 꼬리질문이 받는 유일한 기업 정보. + @Column(nullable = false, columnDefinition = "TEXT") + private String interviewerBrief; + + @Column(nullable = false) + private LocalDate dataAsOf; + + @Column(length = 50) + private String reviewedBy; + + private LocalDate reviewedAt; + + // 가져온 패키지 파일 바이트의 SHA-256. 같으면 다시 가져오지 않는다. + @Column(nullable = false, length = 64) + private String contentHash; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/repit/repit_api_server/domain/company/entity/CompanyFactEntity.java b/src/main/java/repit/repit_api_server/domain/company/entity/CompanyFactEntity.java new file mode 100644 index 00000000..d84d315c --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/entity/CompanyFactEntity.java @@ -0,0 +1,62 @@ +package repit.repit_api_server.domain.company.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import repit.repit_api_server.domain.company.entity.enums.CompanyFactKind; +import repit.repit_api_server.domain.company.entity.enums.SignalsOrigin; + +import java.util.List; + +/** + * 기업 정보 한 줄. 패키지를 다시 가져오면 지우고 다시 넣으므로 fact_id가 바뀐다. + * 이미 만든 면접은 스냅샷 안의 factId를 쓰므로 영향이 없다. + */ +@Entity +@Table(name = "company_fact") +@Getter +@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class CompanyFactEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "fact_id") + private Long factId; + + @Column(nullable = false) + private Long companyId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private CompanyFactKind kind; + + // 짧은 이름. 목록 카드 키워드와 피드백 태그에 쓴다. + @Column(nullable = false, length = 20) + private String title; + + @Column(nullable = false, length = 200) + private String content; + + // 인재상(TALENT)에만 있다. 나머지는 빈 목록이다. + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private List positiveSignals; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, columnDefinition = "jsonb") + private List concernSignals; + + @Enumerated(EnumType.STRING) + @Column(length = 10) + private SignalsOrigin signalsOrigin; + + @Column(nullable = false) + private Long sourceId; + + // 패키지 파일 안 순서(0부터). 표시·전달 순서다. + @Column(nullable = false) + private int sortOrder; +} diff --git a/src/main/java/repit/repit_api_server/domain/company/entity/CompanySourceEntity.java b/src/main/java/repit/repit_api_server/domain/company/entity/CompanySourceEntity.java new file mode 100644 index 00000000..ed67e067 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/entity/CompanySourceEntity.java @@ -0,0 +1,47 @@ +package repit.repit_api_server.domain.company.entity; + +import jakarta.persistence.*; +import lombok.*; +import repit.repit_api_server.domain.company.entity.enums.CompanySourceType; + +import java.time.LocalDate; + +/** 기업 정보의 출처. 화면·프롬프트에는 나가지 않고 DB에만 둔다. */ +@Entity +@Table(name = "company_source") +@Getter +@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class CompanySourceEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "source_id") + private Long sourceId; + + @Column(nullable = false) + private Long companyId; + + // 패키지 안에서 fact가 출처를 가리키는 키. + @Column(nullable = false, length = 40) + private String sourceKey; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private CompanySourceType sourceType; + + @Column(nullable = false, columnDefinition = "TEXT") + private String url; + + @Column(length = 200) + private String title; + + @Column(length = 100) + private String scope; + + private LocalDate publishedAt; + + @Column(nullable = false) + private LocalDate collectedAt; +} diff --git a/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanyCategory.java b/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanyCategory.java new file mode 100644 index 00000000..f015de66 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanyCategory.java @@ -0,0 +1,11 @@ +package repit.repit_api_server.domain.company.entity.enums; + +/** 화면 분류. 선언 순서가 목록 정렬 순서다(00 §5). */ +public enum CompanyCategory { + IT_SERVICE, + PLATFORM, + FINTECH, + GAME, + CONSUMER, + SECURITY +} diff --git a/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanyFactKind.java b/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanyFactKind.java new file mode 100644 index 00000000..4d77a37a --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanyFactKind.java @@ -0,0 +1,10 @@ +package repit.repit_api_server.domain.company.entity.enums; + +/** 기업 정보 종류. 선언 순서가 프롬프트 블록의 표시 순서다(00 §5). */ +public enum CompanyFactKind { + BUSINESS, + PROBLEM, + TALENT, + MISSION_VISION, + TECH_STACK +} diff --git a/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanySourceType.java b/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanySourceType.java new file mode 100644 index 00000000..0df00f3e --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanySourceType.java @@ -0,0 +1,14 @@ +package repit.repit_api_server.domain.company.entity.enums; + +public enum CompanySourceType { + CAREER_PAGE, + ABOUT_PAGE, + TECH_BLOG, + DART, + FTC, + MID_SIZE_REGISTRY, + SME_REGISTRY, + GITHUB, + JOB_API, + SAMPLE +} 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 new file mode 100644 index 00000000..3c2499f2 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/entity/enums/CompanyStatus.java @@ -0,0 +1,7 @@ +package repit.repit_api_server.domain.company.entity.enums; + +/** 기업 행은 지우지 않는다. 패키지에서 빠지면 HIDDEN이 되고 목록·면접 생성에서 보이지 않는다. */ +public enum CompanyStatus { + PUBLISHED, + HIDDEN +} diff --git a/src/main/java/repit/repit_api_server/domain/company/entity/enums/LegalSize.java b/src/main/java/repit/repit_api_server/domain/company/entity/enums/LegalSize.java new file mode 100644 index 00000000..1340674f --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/entity/enums/LegalSize.java @@ -0,0 +1,8 @@ +package repit.repit_api_server.domain.company.entity.enums; + +/** 법적 규모. 확인하지 못했으면 값이 없다 — 모른다고 SMALL로 채우지 않는다. */ +public enum LegalSize { + LARGE, + MID, + SMALL +} diff --git a/src/main/java/repit/repit_api_server/domain/company/entity/enums/SignalsOrigin.java b/src/main/java/repit/repit_api_server/domain/company/entity/enums/SignalsOrigin.java new file mode 100644 index 00000000..c149d5f5 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/entity/enums/SignalsOrigin.java @@ -0,0 +1,7 @@ +package repit.repit_api_server.domain.company.entity.enums; + +/** 인재상 신호의 출처. SOURCE는 출처에 행동 기준이 있는 것, DERIVED는 가치 문구에서 서비스가 만든 연습용 해석이다. */ +public enum SignalsOrigin { + SOURCE, + DERIVED +} 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 new file mode 100644 index 00000000..f308a3e4 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataImporter.java @@ -0,0 +1,361 @@ +package repit.repit_api_server.domain.company.importer; + +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.util.StringUtils; +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.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 tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 기동할 때 기업 데이터 패키지(JSON)를 읽어 DB에 반영한다. + * + *

기동을 실패시키지 않는다. 데이터 문제로 API 전체가 뜨지 않으면 안 된다. 문제가 있는 파일만 건너뛰고 + * 오류 로그를 남긴다. 위치를 비워 두면 아무것도 하지 않고, 공개 기업은 기존 DB 그대로다. + * + *

API는 1대 전제다. 여러 대가 동시에 가져와도 code UNIQUE와 기업별 트랜잭션 때문에 데이터가 깨지지는 않는다. + * + *

웹 서버가 요청을 받기 전에 끝낸다. ApplicationRunner는 내장 서버가 뜬 뒤에 돌아, 그 사이 들어온 요청이 반쯤 반영된 + * 기업 목록을 보거나 갱신 중인 기업으로 면접을 만든다. 싱글턴이 모두 만들어진 뒤(마이그레이션·JPA 준비 완료)이고 + * 서버가 뜨기 전인 이 자리에서 돈다. + */ +@Component +@RequiredArgsConstructor +public class CompanyDataImporter implements SmartInitializingSingleton { + + private static final Logger log = LoggerFactory.getLogger(CompanyDataImporter.class); + + private final CompanyRepository companyRepository; + private final CompanySourceRepository companySourceRepository; + private final CompanyFactRepository companyFactRepository; + private final TransactionTemplate transactionTemplate; + private final ObjectMapper objectMapper; + private final ResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver(); + + // Spring 리소스 패턴. 예: classpath:company-data/sample/*.json + @Value("${app.company.data-location:}") + private String dataLocation; + + @Override + public void afterSingletonsInstantiated() { + try { + importFrom(dataLocation); + } catch (Exception e) { + log.error("기업 데이터를 가져오지 못했습니다. 기존 기업 정보로 기동합니다. location={}", dataLocation, e); + } + } + + public Result importFrom(String location) throws IOException { + if (location == null || location.isBlank()) { + log.info("기업 데이터 위치가 설정되지 않아 가져오지 않습니다"); + return Result.NONE; + } + Resource[] resources = resourceResolver.getResources(location); + if (resources.length == 0) { + // 위치를 잘못 적었을 수 있다. 여기서 숨김까지 하면 공개 기업이 모두 사라진다. + log.warn("기업 데이터 파일이 없어 가져오지 않습니다. location={}", location); + return Result.NONE; + } + Arrays.sort(resources, Comparator.comparing(Resource::getFilename)); + + // 이번에 읽은 기업. 검증에 실패한 파일도 넣어, 그 기업은 숨기지 않고 기존 상태를 둔다. + Set readCodes = new HashSet<>(); + List loaded = new ArrayList<>(); + int skipped = 0; + for (Resource resource : resources) { + String fileName = resource.getFilename(); + readCodes.add(StringUtils.stripFilenameExtension(fileName)); + + byte[] bytes; + CompanyDataPackage pkg; + try { + bytes = resource.getContentAsByteArray(); + pkg = objectMapper.readValue(bytes, CompanyDataPackage.class); + } catch (IOException | JacksonException e) { + log.error("기업 데이터 파일을 읽지 못해 건너뜁니다. file={}, 오류={}", fileName, e.getMessage()); + skipped++; + continue; + } + if (pkg == null) { + log.error("기업 데이터 파일이 비어 있어 건너뜁니다. file={}", fileName); + skipped++; + continue; + } + if (pkg.code() != null) { + readCodes.add(pkg.code()); + } + + List errors = CompanyDataValidator.validate(fileName, pkg); + if (!errors.isEmpty()) { + log.error("기업 데이터 파일이 규칙에 맞지 않아 건너뜁니다. file={}, 오류={}", fileName, errors); + skipped++; + 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)); + transactionTemplate.executeWithoutResult(status -> releaseClaimedIdentifiers(unique)); + + Map tally = new EnumMap<>(Outcome.class); + List applied = new ArrayList<>(); + List retry = new ArrayList<>(); + for (Loaded each : unique) { + Outcome outcome = applyInOwnTransaction(each, true); + if (outcome == null) { + retry.add(each); + } else { + tally.merge(outcome, 1, Integer::sum); + applied.add(each.pkg()); + } + } + // 다른 기업이 먼저 놓아야 받을 수 있는 값(이 묶음 안에서 DART 고유번호를 옮긴 경우)은 처리 순서에 따라 처음에 막힌다. + // 나머지를 반영한 뒤 한 번 더 해 순서와 상관없이 같은 결과가 나오게 한다. + for (Loaded each : retry) { + Outcome outcome = applyInOwnTransaction(each, false); + if (outcome == null) { + skipped++; + } else { + tally.merge(outcome, 1, Integer::sum); + applied.add(each.pkg()); + } + } + + transactionTemplate.executeWithoutResult(status -> linkParents(applied, readCodes)); + + Result result = new Result(tally.getOrDefault(Outcome.CREATED, 0), tally.getOrDefault(Outcome.UPDATED, 0), + tally.getOrDefault(Outcome.UNCHANGED, 0), hidden, skipped); + log.info("기업 데이터 가져오기: 새로 {}, 갱신 {}, 그대로 {}, 숨김 {}, 건너뜀 {}", + result.created(), result.updated(), result.unchanged(), result.hidden(), result.skipped()); + return result; + } + + /** 기업 하나 = 트랜잭션 하나. 실패하면 그 기업만 롤백하고 null을 돌려준다. */ + private Outcome applyInOwnTransaction(Loaded each, boolean firstTry) { + try { + return transactionTemplate.execute(status -> apply(each)); + } catch (RuntimeException e) { + if (firstTry) { + log.warn("기업 데이터를 반영하지 못해 나머지를 반영한 뒤 한 번 더 해봅니다. file={}, 오류={}", + each.fileName(), e.getMessage()); + } else { + log.error("기업 데이터를 반영하지 못해 건너뜁니다. file={}", each.fileName(), e); + } + return null; + } + } + + /** 숨긴 기업이 들고 있는 DART 고유번호·사업자등록번호를 이번 묶음이 쓰면 놓게 한다. 숨긴 행은 지우지 않는다. */ + private void releaseClaimedIdentifiers(List loaded) { + List dartCorpCodes = loaded.stream() + .map(each -> each.pkg().dartCorpCode()).filter(Objects::nonNull).toList(); + List businessRegNos = loaded.stream() + .map(each -> each.pkg().businessRegNo()).filter(Objects::nonNull).toList(); + int released = 0; + if (!dartCorpCodes.isEmpty()) { + released += companyRepository.releaseHiddenDartCorpCodes(dartCorpCodes); + } + if (!businessRegNos.isEmpty()) { + released += companyRepository.releaseHiddenBusinessRegNos(businessRegNos); + } + if (released > 0) { + log.info("숨긴 기업이 들고 있던 식별자를 이번 기업 데이터에 넘겼습니다. {}건", released); + } + } + + /** 묶음 규칙 8. code·DART 고유번호·사업자등록번호가 다른 파일과 겹치면 어느 쪽이 맞는지 몰라 둘 다 건너뛴다. */ + private List withoutDuplicates(List loaded) { + Map counts = loaded.stream() + .flatMap(each -> uniqueKeys(each.pkg()).stream()) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + + List unique = new ArrayList<>(); + for (Loaded each : loaded) { + List duplicated = uniqueKeys(each.pkg()).stream() + .filter(key -> counts.get(key) > 1) + .toList(); + if (duplicated.isEmpty()) { + unique.add(each); + } else { + log.error("다른 기업 데이터 파일과 겹쳐 건너뜁니다. file={}, 겹친 값={}", each.fileName(), duplicated); + } + } + return unique; + } + + private static List uniqueKeys(CompanyDataPackage pkg) { + List keys = new ArrayList<>(); + keys.add("code=" + pkg.code()); + if (pkg.dartCorpCode() != null) { + keys.add("dartCorpCode=" + pkg.dartCorpCode()); + } + if (pkg.businessRegNo() != null) { + keys.add("businessRegNo=" + pkg.businessRegNo()); + } + return keys; + } + + /** + * 기업 하나를 반영한다. 내용이 그대로면 손대지 않고, 바뀌었으면 기업 컬럼을 고친 뒤 정보·출처를 지우고 다시 넣는다. + * + *

다시 넣으면 fact_id가 바뀐다. 이미 만든 면접은 스냅샷 안의 factId를 쓰므로 영향이 없다. + */ + private Outcome apply(Loaded loaded) { + CompanyDataPackage pkg = loaded.pkg(); + CompanyEntity company = companyRepository.findByCode(pkg.code()).orElse(null); + if (company != null && loaded.hash().equals(company.getContentHash()) + && company.getStatus() == CompanyStatus.PUBLISHED) { + return Outcome.UNCHANGED; + } + + Outcome outcome; + if (company == null) { + company = CompanyEntity.builder().code(pkg.code()).build(); + outcome = Outcome.CREATED; + } else { + // fact가 출처를 가리키므로 fact부터 지운다. + companyFactRepository.deleteAllByCompanyId(company.getCompanyId()); + companySourceRepository.deleteAllByCompanyId(company.getCompanyId()); + outcome = Outcome.UPDATED; + } + company.setDisplayName(pkg.displayName()); + company.setLegalName(pkg.legalName()); + company.setAliases(pkg.aliases()); + company.setCategory(pkg.category()); + company.setLegalSize(pkg.legalSize()); + company.setLegalSizeSource(pkg.legalSizeSource()); + company.setDartCorpCode(pkg.dartCorpCode()); + company.setBusinessRegNo(pkg.businessRegNo()); + company.setStatus(CompanyStatus.PUBLISHED); + company.setInterviewerBrief(pkg.interviewerBrief()); + company.setDataAsOf(pkg.dataAsOf()); + company.setReviewedBy(pkg.review() == null ? null : pkg.review().reviewedBy()); + company.setReviewedAt(pkg.review() == null ? null : pkg.review().reviewedAt()); + company.setContentHash(loaded.hash()); + Long companyId = companyRepository.save(company).getCompanyId(); + + Map sourceIds = new HashMap<>(); + for (CompanyDataPackage.Source source : pkg.sources()) { + CompanySourceEntity saved = companySourceRepository.save(CompanySourceEntity.builder() + .companyId(companyId) + .sourceKey(source.sourceKey()) + .sourceType(source.sourceType()) + .url(source.url()) + .title(source.title()) + .scope(source.scope()) + .publishedAt(source.publishedAt()) + .collectedAt(source.collectedAt()) + .build()); + sourceIds.put(source.sourceKey(), saved.getSourceId()); + } + + List facts = new ArrayList<>(); + for (int order = 0; order < pkg.facts().size(); order++) { + CompanyDataPackage.Fact fact = pkg.facts().get(order); + facts.add(CompanyFactEntity.builder() + .companyId(companyId) + .kind(fact.kind()) + .title(fact.title()) + .content(fact.content()) + .positiveSignals(fact.positiveSignals()) + .concernSignals(fact.concernSignals()) + .signalsOrigin(fact.signalsOrigin()) + .sourceId(sourceIds.get(fact.sourceKey())) + .sortOrder(order) + .build()); + } + companyFactRepository.saveAll(facts); + return outcome; + } + + /** + * 묶음 규칙 10. 모회사는 이번 묶음에 있어야 잇는다. 묶음에 아예 없으면 연결만 빼고 경고한다. + * + *

모회사 파일이 묶음에 있는데 검증·반영에 실패했으면 그 기업 행은 기존 상태로 남는다. 그때는 연결도 지금 그대로 둔다 — + * 모회사 파일 하나가 잠깐 틀렸다고 자회사의 연결까지 끊지 않는다. + */ + private void linkParents(List applied, Set readCodes) { + Set codes = applied.stream().map(CompanyDataPackage::code).collect(Collectors.toSet()); + 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 (readCodes.contains(parentCode)) { + log.warn("모회사 기업 데이터를 반영하지 못해 모회사 연결은 그대로 둡니다. code={}, parentCode={}", + pkg.code(), parentCode); + } else { + log.warn("모회사가 이번 기업 데이터에 없어 연결하지 않습니다. code={}, parentCode={}", + pkg.code(), parentCode); + company.setParent(null); + } + } + } + + /** 이번에 읽은 파일에 아예 없는 공개 기업을 숨긴다. 행은 면접이 가리키고 있어 지우지 않는다. */ + private int hideMissing(Set readCodes) { + int hidden = 0; + for (CompanyEntity company : companyRepository.findAllByStatus(CompanyStatus.PUBLISHED)) { + if (!readCodes.contains(company.getCode())) { + company.setStatus(CompanyStatus.HIDDEN); + hidden++; + } + } + return hidden; + } + + private static String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + 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/CompanyDataPackage.java b/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataPackage.java new file mode 100644 index 00000000..33a54c65 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataPackage.java @@ -0,0 +1,71 @@ +package repit.repit_api_server.domain.company.importer; + +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.LegalSize; +import repit.repit_api_server.domain.company.entity.enums.SignalsOrigin; + +import java.time.LocalDate; +import java.util.List; + +/** + * 기업 데이터 패키지 파일 하나(schemaVersion 1). 파일 하나가 기업 하나다. + * + *

데이터셋과 서버가 만나는 유일한 형식이라, 데이터셋을 어떻게 만들든 이 모양이면 코드 변경 없이 바뀐다. + * 모르는 필드는 무시한다. 코드값이 모르는 값이면 파일을 읽지 못해 그 파일만 건너뛴다. + */ +public record CompanyDataPackage( + Integer schemaVersion, + String code, + String displayName, + String legalName, + List aliases, + String parentCode, + CompanyCategory category, + LegalSize legalSize, + String legalSizeSource, + String dartCorpCode, + String businessRegNo, + LocalDate dataAsOf, + List sources, + List facts, + String interviewerBrief, + Review review) { + + public CompanyDataPackage { + aliases = aliases == null ? List.of() : aliases; + } + + public record Source( + String sourceKey, + CompanySourceType sourceType, + String url, + String title, + String scope, + LocalDate publishedAt, + LocalDate collectedAt) { + } + + public record Fact( + CompanyFactKind kind, + String title, + String content, + String sourceKey, + List positiveSignals, + List concernSignals, + SignalsOrigin signalsOrigin) { + + public Fact { + positiveSignals = positiveSignals == null ? List.of() : positiveSignals; + concernSignals = concernSignals == null ? List.of() : concernSignals; + } + + boolean hasSignals() { + return !positiveSignals.isEmpty() || !concernSignals.isEmpty(); + } + } + + public record Review(String reviewedBy, LocalDate reviewedAt) { + } +} 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 new file mode 100644 index 00000000..9a6ece1b --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/importer/CompanyDataValidator.java @@ -0,0 +1,223 @@ +package repit.repit_api_server.domain.company.importer; + +import repit.repit_api_server.domain.company.CompanyLimits; +import repit.repit_api_server.domain.company.entity.enums.CompanyFactKind; +import repit.repit_api_server.domain.company.entity.enums.CompanySourceType; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +import static repit.repit_api_server.domain.company.CompanyLimits.length; + +/** + * 패키지 파일 하나에 적용하는 검증(01 §3 규칙 1~7, 9). 하나라도 어기면 그 파일만 건너뛴다. + * + *

묶음 전체를 봐야 하는 규칙(8 유일성, 10 모회사)은 모든 파일을 읽은 뒤 가져오기가 본다. + * 여기를 통과한 데이터는 분석 서버에서 422가 나지 않는다 — 상한이 분석 서버 계약과 같다. + */ +public final class CompanyDataValidator { + + static final int SCHEMA_VERSION = 1; + + private static final Pattern CODE = Pattern.compile("^[a-z0-9][a-z0-9-]{1,39}$"); + private static final Pattern DART_CORP_CODE = Pattern.compile("^\\d{8}$"); + private static final Pattern BUSINESS_REG_NO = Pattern.compile("^\\d{10}$"); + // 사실 오인("공식", "기출")과 소속 사칭("저희", "우리 회사")을 막는다. + private static final List FORBIDDEN_WORDS = List.of("공식", "기출", "저희", "우리 회사", "합격"); + + private CompanyDataValidator() { + } + + public static List validate(String fileName, CompanyDataPackage pkg) { + List errors = new ArrayList<>(); + // 형식이 다르면 나머지 규칙은 의미가 없다. + if (!Objects.equals(pkg.schemaVersion(), SCHEMA_VERSION)) { + errors.add("schemaVersion이 " + SCHEMA_VERSION + "이 아닙니다: " + pkg.schemaVersion()); + return errors; + } + + // 1. 필수 필드와 길이·개수 상한 + if (pkg.code() == null || !CODE.matcher(pkg.code()).matches()) { + errors.add("code는 소문자·숫자·하이픈 2~40자여야 합니다: " + pkg.code()); + } + text(errors, "displayName", pkg.displayName(), CompanyLimits.DISPLAY_NAME, true); + text(errors, "legalName", pkg.legalName(), 100, true); + count(errors, "aliases", pkg.aliases(), 0, 10); + for (int i = 0; i < pkg.aliases().size(); i++) { + text(errors, "aliases[" + i + "]", pkg.aliases().get(i), 30, true); + } + required(errors, "category", pkg.category()); + 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); + } + text(errors, "interviewerBrief", pkg.interviewerBrief(), CompanyLimits.INTERVIEWER_BRIEF, true); + + List sources = present(errors, "sources", pkg.sources(), 1, 30); + for (int i = 0; i < sources.size(); i++) { + CompanyDataPackage.Source source = sources.get(i); + String at = "sources[" + i + "]."; + 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()); + } + text(errors, at + "title", source.title(), 200, false); + text(errors, at + "scope", source.scope(), 100, false); + required(errors, at + "collectedAt", source.collectedAt()); + } + + List facts = present(errors, "facts", pkg.facts(), 1, CompanyLimits.FACTS); + for (int i = 0; i < facts.size(); i++) { + CompanyDataPackage.Fact fact = facts.get(i); + String at = "facts[" + i + "]."; + required(errors, at + "kind", fact.kind()); + text(errors, at + "title", fact.title(), CompanyLimits.FACT_TITLE, true); + text(errors, at + "content", fact.content(), CompanyLimits.FACT_CONTENT, true); + required(errors, at + "sourceKey", fact.sourceKey()); + signals(errors, at + "positiveSignals", fact.positiveSignals()); + signals(errors, at + "concernSignals", fact.concernSignals()); + } + + // 2. 파일 이름 + if (pkg.code() != null && !(pkg.code() + ".json").equals(fileName)) { + errors.add("파일 이름이 code와 다릅니다: " + fileName + " (code=" + pkg.code() + ")"); + } + + // 3. 공개 조건 + Set kinds = new HashSet<>(); + facts.forEach(fact -> kinds.add(fact.kind())); + if (!kinds.contains(CompanyFactKind.TALENT)) { + errors.add("TALENT fact가 하나 이상 있어야 합니다."); + } + if (!kinds.contains(CompanyFactKind.BUSINESS)) { + errors.add("BUSINESS fact가 하나 이상 있어야 합니다."); + } + + // 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 < facts.size(); i++) { + String sourceKey = facts.get(i).sourceKey(); + if (sourceKey != null && !sourceKeys.contains(sourceKey)) { + errors.add("facts[" + i + "].sourceKey가 sources에 없습니다: " + sourceKey); + } + } + + // 5. 신호 + for (int i = 0; i < facts.size(); i++) { + CompanyDataPackage.Fact fact = facts.get(i); + if (fact.kind() != CompanyFactKind.TALENT && fact.hasSignals()) { + errors.add("facts[" + i + "]는 TALENT가 아니라 신호를 가질 수 없습니다."); + } + if (fact.hasSignals() && fact.signalsOrigin() == null) { + errors.add("facts[" + i + "]에 신호가 있으면 signalsOrigin이 있어야 합니다."); + } + } + + // 6. 규모 + if (pkg.legalSize() != null && isBlank(pkg.legalSizeSource())) { + errors.add("legalSize가 있으면 legalSizeSource가 있어야 합니다."); + } + + // 7. 금지 표현 + for (int i = 0; i < facts.size(); i++) { + CompanyDataPackage.Fact fact = facts.get(i); + String at = "facts[" + i + "]."; + forbidden(errors, at + "title", fact.title()); + forbidden(errors, at + "content", fact.content()); + fact.positiveSignals().forEach(signal -> forbidden(errors, at + "positiveSignals", signal)); + fact.concernSignals().forEach(signal -> forbidden(errors, at + "concernSignals", signal)); + } + forbidden(errors, "interviewerBrief", pkg.interviewerBrief()); + + // 9. 검수. 샘플이 아닌 실제 데이터는 사람이 확인한 기록이 있어야 한다. + boolean real = sources.stream().anyMatch(source -> source.sourceType() != CompanySourceType.SAMPLE); + CompanyDataPackage.Review review = pkg.review(); + if (real && (review == null || isBlank(review.reviewedBy()) || review.reviewedAt() == null)) { + errors.add("샘플이 아닌 출처가 있으면 review.reviewedBy와 review.reviewedAt이 있어야 합니다."); + } + return errors; + } + + /** 비어 있지 않은 원소만 돌려준다. 개수와 빈 원소는 오류로 남긴다. */ + private static List present(List errors, String field, List values, int min, int max) { + if (values == null) { + errors.add(field + " 값이 없습니다."); + return List.of(); + } + count(errors, field, values, min, max); + if (values.stream().anyMatch(Objects::isNull)) { + errors.add(field + "에 빈 항목이 있습니다."); + } + return values.stream().filter(Objects::nonNull).toList(); + } + + private static void signals(List errors, String field, List signals) { + count(errors, field, signals, 0, CompanyLimits.SIGNALS_PER_FACT); + for (int i = 0; i < signals.size(); i++) { + text(errors, field + "[" + i + "]", signals.get(i), CompanyLimits.SIGNAL, true); + } + } + + private static void text(List errors, String field, String value, int max, boolean required) { + if (isBlank(value)) { + if (required) { + errors.add(field + " 값이 없습니다."); + } + return; + } + if (length(value) > max) { + errors.add(field + "는 " + max + "자 이하여야 합니다: " + length(value) + "자"); + } + } + + private static void count(List errors, String field, Collection values, int min, int max) { + if (values.size() < min || values.size() > max) { + errors.add(field + "는 " + min + "~" + max + "개여야 합니다: " + values.size() + "개"); + } + } + + private static void required(List errors, String field, Object value) { + if (value == null) { + errors.add(field + " 값이 없습니다."); + } + } + + 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); + } + } + + private static void forbidden(List errors, String field, String value) { + if (value == null) { + return; + } + for (String word : FORBIDDEN_WORDS) { + if (value.contains(word)) { + errors.add(field + "에 쓸 수 없는 표현이 있습니다: \"" + word + "\""); + } + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/company/repository/CompanyFactRepository.java b/src/main/java/repit/repit_api_server/domain/company/repository/CompanyFactRepository.java new file mode 100644 index 00000000..fd131613 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/repository/CompanyFactRepository.java @@ -0,0 +1,22 @@ +package repit.repit_api_server.domain.company.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import repit.repit_api_server.domain.company.entity.CompanyFactEntity; +import repit.repit_api_server.domain.company.entity.enums.CompanyFactKind; + +import java.util.List; + +public interface CompanyFactRepository extends JpaRepository { + + List findAllByCompanyIdOrderBySortOrder(Long companyId); + + // 목록 카드의 인재상 키워드. 기업마다 따로 읽지 않고 한 번에 읽어 기업별로 나눈다. + List findAllByKindOrderBySortOrder(CompanyFactKind kind); + + // 출처보다 먼저 지운다(fact가 출처를 가리킨다). 출처와 같은 이유로 한 문장으로 지운다. + @Modifying + @Query("delete from CompanyFactEntity f where f.companyId = :companyId") + void deleteAllByCompanyId(Long companyId); +} 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 new file mode 100644 index 00000000..75207359 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/repository/CompanyRepository.java @@ -0,0 +1,45 @@ +package repit.repit_api_server.domain.company.repository; + +import org.springframework.data.jpa.repository.EntityGraph; +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 repit.repit_api_server.domain.company.entity.CompanyEntity; +import repit.repit_api_server.domain.company.entity.enums.CompanyStatus; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface CompanyRepository extends JpaRepository { + + Optional findByCode(String code); + + List findAllByStatus(CompanyStatus status); + + // 목록에 모회사 이름을 함께 싣는다. 모회사를 기업마다 따로 읽지 않도록 한 번에 가져온다. + @EntityGraph(attributePaths = "parent") + List findWithParentByStatus(CompanyStatus status); + + Optional findByCompanyIdAndStatus(Long companyId, CompanyStatus status); + + // 숨긴 기업이 들고 있는 식별자를 놓게 한다. 같은 법인을 다른 code로 다시 낸 패키지가 UNIQUE에 걸리지 않게 한다. + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(""" + update CompanyEntity c + set c.dartCorpCode = null + where c.status = repit.repit_api_server.domain.company.entity.enums.CompanyStatus.HIDDEN + and c.dartCorpCode in :dartCorpCodes + """) + int releaseHiddenDartCorpCodes(@Param("dartCorpCodes") Collection dartCorpCodes); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(""" + update CompanyEntity c + set c.businessRegNo = null + where c.status = repit.repit_api_server.domain.company.entity.enums.CompanyStatus.HIDDEN + and c.businessRegNo in :businessRegNos + """) + int releaseHiddenBusinessRegNos(@Param("businessRegNos") Collection businessRegNos); +} diff --git a/src/main/java/repit/repit_api_server/domain/company/repository/CompanySourceRepository.java b/src/main/java/repit/repit_api_server/domain/company/repository/CompanySourceRepository.java new file mode 100644 index 00000000..6718486f --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/repository/CompanySourceRepository.java @@ -0,0 +1,15 @@ +package repit.repit_api_server.domain.company.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import repit.repit_api_server.domain.company.entity.CompanySourceEntity; + +public interface CompanySourceRepository extends JpaRepository { + + // 한 문장으로 바로 지운다. 하나씩 지우면 Hibernate가 플러시에서 삽입을 삭제보다 먼저 내보내, + // 같은 출처 키를 다시 넣을 때 (company_id, source_key) 유일 제약에 걸린다. + @Modifying + @Query("delete from CompanySourceEntity s where s.companyId = :companyId") + void deleteAllByCompanyId(Long companyId); +} 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 new file mode 100644 index 00000000..00c12655 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/service/CompanyQueryService.java @@ -0,0 +1,95 @@ +package repit.repit_api_server.domain.company.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +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.enums.CompanyCategory; +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.exception.BusinessException; + +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * 공개 기업 목록·검색. + * + *

공개 기업이 30개 안팎이라 요청마다 전부 읽어 메모리에서 거른다. 캐시는 두지 않는다. + */ +@Service +@RequiredArgsConstructor +public class CompanyQueryService { + + private static final int QUERY_MIN_LENGTH = 2; + private static final int QUERY_MAX_LENGTH = 30; + private static final int TALENT_KEYWORDS = 3; + private static final Pattern UNICODE_WHITESPACE = Pattern.compile("(?U)\\s+"); + + private final CompanyRepository companyRepository; + private final CompanyFactRepository companyFactRepository; + + @Transactional(readOnly = true) + public List search(String q, CompanyCategory category) { + String query = q == null ? "" : normalize(q); + if (!query.isEmpty() && (query.length() < QUERY_MIN_LENGTH || query.length() > QUERY_MAX_LENGTH)) { + throw BusinessException.unprocessable( + "검색어는 " + QUERY_MIN_LENGTH + "~" + QUERY_MAX_LENGTH + "자로 입력해주세요."); + } + + List companies = companyRepository.findWithParentByStatus(CompanyStatus.PUBLISHED).stream() + .filter(company -> category == null || company.getCategory() == category) + .filter(company -> query.isEmpty() || matches(company, query)) + .sorted(Comparator.comparing(CompanyEntity::getCategory) + .thenComparing(CompanyEntity::getDisplayName)) + .toList(); + if (companies.isEmpty()) { + return List.of(); + } + + Map> talentKeywords = companyFactRepository + .findAllByKindOrderBySortOrder(CompanyFactKind.TALENT).stream() + .collect(Collectors.groupingBy(CompanyFactEntity::getCompanyId, + Collectors.mapping(CompanyFactEntity::getTitle, Collectors.toList()))); + + return companies.stream() + .map(company -> new CompanySummaryResponse( + company.getCompanyId(), + company.getDisplayName(), + company.getLegalName(), + parentDisplayName(company), + company.getCategory(), + company.getLegalSize(), + talentKeywords.getOrDefault(company.getCompanyId(), List.of()).stream() + .limit(TALENT_KEYWORDS) + .toList(), + company.getDataAsOf())) + .toList(); + } + + // 숨긴 모회사의 이름은 내보내지 않는다. 정보 수정·삭제 요청으로 내린 기업 이름이 계열사 카드에 계속 남는다. + private static String parentDisplayName(CompanyEntity company) { + CompanyEntity parent = company.getParent(); + return parent == null || parent.getStatus() != CompanyStatus.PUBLISHED ? null : parent.getDisplayName(); + } + + /** 보이는 이름, 법인 이름, 별칭 가운데 하나라도 검색어를 품으면 맞다. 공백·대소문자는 보지 않는다. */ + private boolean matches(CompanyEntity company, String query) { + return Stream.concat(Stream.of(company.getDisplayName(), company.getLegalName()), company.getAliases().stream()) + .anyMatch(name -> normalize(name).contains(query)); + } + + // 유니코드 공백까지 지운다. 다른 곳에서 붙여 넣은 검색어에는 NBSP·전각 공백이 섞여 온다. + private static String normalize(String value) { + return UNICODE_WHITESPACE.matcher(value).replaceAll("").toLowerCase(Locale.ROOT); + } +} 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 new file mode 100644 index 00000000..9f689ed5 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/company/service/CompanySnapshotFactory.java @@ -0,0 +1,87 @@ +package repit.repit_api_server.domain.company.service; + +import lombok.RequiredArgsConstructor; +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.exception.BusinessException; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static repit.repit_api_server.domain.company.CompanyLimits.length; + +/** 면접을 만들 때 공개 기업을 스냅샷으로 복사한다. 이후 면접의 모든 경로는 이 스냅샷만 읽는다. */ +@Component +@RequiredArgsConstructor +public class CompanySnapshotFactory { + + private final CompanyRepository companyRepository; + private final CompanyFactRepository companyFactRepository; + + // 기업 행과 fact를 두 문장으로 읽는다. 그 사이 가져오기가 커밋되면 이전 요약과 새 fact가 섞인 스냅샷이 면접에 영구히 + // 남는다. REPEATABLE READ면 두 문장이 같은 시점을 본다. + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public CompanySnapshot create(Long companyId) { + CompanyEntity company = companyRepository.findByCompanyIdAndStatus(companyId, CompanyStatus.PUBLISHED) + .orElseThrow(() -> BusinessException.notFound("선택한 기업을 찾을 수 없습니다.")); + + List facts = companyFactRepository.findAllByCompanyIdOrderBySortOrder(companyId).stream() + .map(this::toFact) + .toList(); + CompanySnapshot snapshot = new CompanySnapshot(String.valueOf(company.getCompanyId()), + company.getDisplayName(), company.getCategory().name(), facts, company.getInterviewerBrief()); + verifyLimits(snapshot); + return snapshot; + } + + private 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()); + } + + /** + * 분석 서버 계약(00 §6-1). 어기면 분석 서버가 재작성·피드백 요청을 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()); + } + } + + private boolean withinSignals(List signals) { + return signals.size() <= CompanyLimits.SIGNALS_PER_FACT + && signals.stream().allMatch(signal -> within(signal, CompanyLimits.SIGNAL)); + } + + /** 1자 이상, 상한 이하. 분석 서버는 빈 문자열도 거부한다. */ + private static boolean within(String value, int max) { + return value != null && !value.isBlank() && length(value) <= max; + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java index 4371d15e..be455677 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java @@ -30,6 +30,8 @@ public static class Result { // N:1 면접에만 실린다. 1:1 콜백에는 없다. private List personas; private List feedbacks; + // 기업 관점 코멘트. 기업을 고르지 않았거나 만들지 못했으면 null이다. 푸는 것은 CompanyFitReader가 한다. + private JsonNode companyFit; } @Getter diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java index 3310788c..34eb0988 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java @@ -1,9 +1,11 @@ package repit.repit_api_server.domain.userdata.feedback.dto.request; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import repit.repit_api_server.domain.company.dto.CompanySnapshot; import repit.repit_api_server.domain.userdata.question.entity.enums.Type; import java.time.OffsetDateTime; @@ -29,6 +31,10 @@ public class FeedbackMultiRequest { private List questions; private List answers; private String callbackUrl; + // 면접을 만들 때 고른 기업. 채점 요청이 아니라 기업 관점 코멘트(companyFit)를 만드는 데만 쓴다. + // 고르지 않은 면접은 키 자체를 보내지 않는다 — null도 보내지 않는다. + @JsonInclude(JsonInclude.Include.NON_NULL) + private CompanySnapshot company; /** role(직책)이 채점 관점을, style(성향)이 짚을 대상을, tone(어조)이 피드백 어조를 정한다. */ @Getter diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java index 7ee4000c..53d6189c 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java @@ -1,9 +1,11 @@ package repit.repit_api_server.domain.userdata.feedback.dto.request; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import repit.repit_api_server.domain.company.dto.CompanySnapshot; import repit.repit_api_server.domain.userdata.question.entity.enums.Type; import java.time.OffsetDateTime; @@ -23,6 +25,10 @@ public class FeedbackSoloRequest { private String callbackUrl; private List questions; private List answers; + // 면접을 만들 때 고른 기업. 채점 요청이 아니라 기업 관점 코멘트(companyFit)를 만드는 데만 쓴다. + // 고르지 않은 면접은 키 자체를 보내지 않는다 — null도 보내지 않는다. + @JsonInclude(JsonInclude.Include.NON_NULL) + private CompanySnapshot company; @Getter @Builder 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 new file mode 100644 index 00000000..e0c2d8bf --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/CompanyFitResponse.java @@ -0,0 +1,29 @@ +package repit.repit_api_server.domain.userdata.feedback.dto.response; + +import java.util.List; + +/** + * 피드백의 "○○ 기준으로 보면" 섹션(00 §6-2). 점수와 무관하다 — 종합 점수와 산출 근거 계산에 들어가지 않는다. + * + *

kind·focus는 분석 서버가 요청의 fact에서 복사해 채운다. focus는 fact의 title이다. + * questionIds는 분석 서버에 보낸 질문 id 그대로라, 웹은 feedbacks[].questionId와 맞춰 문항을 잇는다. + */ +public record CompanyFitResponse(String comment, List matchedFocus, List gaps) { + + public CompanyFitResponse { + matchedFocus = matchedFocus == null ? List.of() : matchedFocus; + gaps = gaps == null ? List.of() : gaps; + } + + public record Item(String factId, String kind, String focus, String evidence, List questionIds) { + public Item { + questionIds = questionIds == null ? List.of() : questionIds; + } + } + + public record Gap(String factId, String kind, String focus, String suggestion, List questionIds) { + public Gap { + questionIds = questionIds == null ? List.of() : questionIds; + } + } +} 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 f1a03544..8c435241 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 @@ -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.feedback.entity.FeedbackEntity; import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackItemEntity; import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackPersonaEntity; @@ -64,6 +65,11 @@ public class FeedbackResponse { private List feedbacks; private LocalDateTime createdAt; + // 면접을 만들 때 고른 기업. 고르지 않았으면 비어 있다. + private CompanyBadge company; + // 기업 관점 코멘트. 기업이 없거나 분석 서버가 만들지 못했으면 비어 있다. + private CompanyFitResponse companyFit; + /** * @param interview 이 피드백이 나온 면접. 면접이 남아 있지 않으면 null이다. * @param persona 이 면접의 면접관. 성향·난이도는 채점 결과가 아니라 여기서 읽는다. @@ -97,6 +103,8 @@ 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()) .build(); } } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/entity/FeedbackEntity.java index ef2ceed7..de3aa67f 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 @@ -6,6 +6,7 @@ import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.type.SqlTypes; +import repit.repit_api_server.domain.userdata.feedback.dto.response.CompanyFitResponse; import repit.repit_api_server.domain.userdata.feedback.dto.response.FrequentWordResponse; import repit.repit_api_server.domain.userdata.feedback.dto.response.ScoreBreakdownResponse; import repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackStatus; @@ -75,6 +76,11 @@ public class FeedbackEntity { @Column(columnDefinition = "jsonb") private List frequentWords; + // 기업 관점 코멘트. 기업을 고르지 않은 면접이거나 분석 서버가 만들지 못했으면 비어 있다. 점수와 무관하다. + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private CompanyFitResponse companyFit; + private Integer answeredCount; private Integer questionCount; 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 new file mode 100644 index 00000000..0c71ecb6 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/CompanyFitReader.java @@ -0,0 +1,42 @@ +package repit.repit_api_server.domain.userdata.feedback.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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; + +/** + * 채점 콜백의 companyFit을 읽는다. + * + *

{@link ScoreBreakdownReader}와 같은 이유로 요청 본문에서는 모양을 가리지 않고 받아 여기서 푼다. 타입을 정해 + * 받으면 모양이 하나만 어긋나도 본문 전체를 읽지 못해 채점 결과까지 잃는다. 기업 관점 코멘트는 없어도 피드백이 + * 성립하는 부가 정보라, 읽지 못하면 비워 두고 어긋난 사실을 로그로 남긴다. + */ +final class CompanyFitReader { + + private static final Logger log = LoggerFactory.getLogger(CompanyFitReader.class); + + private CompanyFitReader() { + } + + static CompanyFitResponse read(JsonNode node, 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; + } + return fit; + } catch (JacksonException e) { + log.warn("기업 관점 코멘트를 읽지 못해 비워 둡니다. interviewId={}, 받은 값={}, 오류={}", + interviewId, node, e.getOriginalMessage()); + 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 488a70f9..9f08dd5b 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 @@ -261,6 +261,7 @@ private FeedbackSoloRequest toSoloRequest(InterviewEntity interview) { .userId(String.valueOf(interview.getUserId())) .personaType(enumName(persona == null ? null : persona.getType())) .personaTone(enumName(persona == null ? null : persona.getTone())) + .company(interview.getCompanySnapshot()) .callbackUrl(callbackBaseUrl + CALLBACK_PATH) .questions(transcript.questions().stream() .map(question -> toQuestion(question, transcript.questionIds())) @@ -300,6 +301,7 @@ private FeedbackMultiRequest toMultiRequest(InterviewEntity interview) { .tone(enumName(persona.getTone())) .build()) .toList()) + .company(interview.getCompanySnapshot()) .callbackUrl(callbackBaseUrl + CALLBACK_PATH) .questions(transcript.questions().stream() .map(question -> toMultiQuestion(question, transcript.questionIds(), personaByQuestion)) @@ -597,6 +599,8 @@ private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Resul feedback.setQuestionCount(counts.questionCountOr(overall.getQuestionCount(), "면접 전체 문항 수", interviewId)); } + // 기업 관점 코멘트. 읽지 못해도 피드백은 그대로 저장한다. + feedback.setCompanyFit(CompanyFitReader.read(result.getCompanyFit(), interviewId)); feedback.setStatus(FeedbackStatus.SUCCEEDED); feedback.setErrorStatusCode(null); feedback.setErrorMessage(null); diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java index 594f95c8..4288b3e8 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java @@ -1,9 +1,11 @@ package repit.repit_api_server.domain.userdata.interview.dto.request; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import repit.repit_api_server.domain.company.dto.ChatCompany; 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.persona.entity.enums.Gender; @@ -76,6 +78,9 @@ public class ChatInterviewPrepareRequest { * 면접관별 음성과 억양을 고른다. 대표 설정은 이 필드를 모르는 채팅 서버를 위해 그대로 둔다. */ private List personas; + // 면접을 만들 때 고른 기업의 요약. 꼬리질문이 이것으로 기업을 반영한다. 고르지 않은 면접은 키 자체를 보내지 않는다. + @JsonInclude(JsonInclude.Include.NON_NULL) + private ChatCompany company; /** * 면접 질문 한 건. 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 bcd5c15d..cd2889bd 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 @@ -17,6 +17,8 @@ * 값이라 id 쪽이 안전하지만, 웹이 이름으로 보내던 기존 방식도 그대로 받는다. * *

N:1 면접은 면접관이 여럿이라 {@code personaIds}로 보낸다. 이 값이 있으면 위 두 값은 보지 않는다. + * + *

{@code companyId}는 선택이다. 고르면 그 기업 정보를 면접에 스냅샷으로 남긴다. 1:1·N:1 모두 받는다. */ @Getter @Setter @@ -29,4 +31,5 @@ public class CreateInterviewRequest { // 직책은 겹칠 수 없고, 진행 순서에서 기술 면접관을 맨 앞으로 올리는 것 말고는 담아 보낸 // 순서를 그대로 쓴다. private List personaIds; + private Long companyId; } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java index 7f0b5f00..d8118345 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +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.interview.entity.enums.InterviewMode; import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; @@ -26,6 +27,8 @@ public class InterviewResponse { private String sessionId; private Status status; private LocalDateTime createdAt; + // 면접을 만들 때 고른 기업. 고르지 않았으면 비어 있다. + private CompanyBadge company; public static InterviewResponse from(InterviewEntity interview) { return from(interview, List.of()); @@ -40,7 +43,8 @@ public static InterviewResponse from(InterviewEntity interview, List perso personaIds, interview.getSessionId(), interview.getStatus(), - interview.getCreatedAt() + interview.getCreatedAt(), + CompanyBadge.of(interview.getCompanyId(), interview.getCompanySnapshot()) ); } } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java index 7f7fc48b..2d59c401 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java @@ -3,6 +3,9 @@ import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import repit.repit_api_server.domain.company.dto.CompanySnapshot; import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; @@ -39,6 +42,15 @@ public class InterviewEntity { @Column(nullable = false) private Status status; + // 면접을 만들 때 고른 기업. 고르지 않았으면 비어 있다. + private Long companyId; + + // 만들 때 복사해 둔 기업 정보. 재작성·채팅·피드백 요청은 기업 테이블을 다시 읽지 않고 이것만 쓴다. + // 기업 정보가 나중에 바뀌어도 이 면접은 만들 때 기준으로 남는다. + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private CompanySnapshot companySnapshot; + @CreationTimestamp @Column(nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java index b2807467..7e2fb96b 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java @@ -70,6 +70,7 @@ public ChatInterviewResponse deliver(QuestionTailorEntity tailor) { .questions(toQuestions(tailor, defaultPersonaId(interview, persona), personas.stream().map(PersonaEntity::getPersonaId).collect(Collectors.toSet()))) .personas(personas.stream().map(this::toPersona).toList()) + .company(interview.getCompanySnapshot() == null ? null : interview.getCompanySnapshot().toChat()) .build()); } 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 2ed9344d..da2cccaa 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 @@ -5,6 +5,8 @@ import org.slf4j.LoggerFactory; 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.company.service.CompanySnapshotFactory; import repit.repit_api_server.domain.metadata.service.AnalysisLaunchService; import repit.repit_api_server.domain.userdata.interview.dto.request.CreateInterviewRequest; import repit.repit_api_server.domain.userdata.interview.dto.request.SaveInterviewRequest; @@ -64,10 +66,11 @@ public class InterviewService { private final QuestionTailorService questionTailorService; private final InterviewPersonaRepository interviewPersonaRepository; private final AnalysisLaunchService analysisLaunchService; + private final CompanySnapshotFactory companySnapshotFactory; public InterviewResponse createInterview(Long userId, CreateInterviewRequest request) { if (request.getPersonaIds() != null && !request.getPersonaIds().isEmpty()) { - return createMultiInterview(userId, request.getPersonaIds()); + return createMultiInterview(userId, request.getPersonaIds(), request.getCompanyId()); } PersonaEntity persona = findPersona(request); @@ -78,6 +81,8 @@ public InterviewResponse createInterview(Long userId, CreateInterviewRequest req .personaId(persona.getPersonaId()) .status(Status.IN_PROGRESS) .sessionId(UUID.randomUUID().toString()) + .companyId(request.getCompanyId()) + .companySnapshot(companySnapshotOf(request.getCompanyId())) .build(); InterviewEntity saved = interviewRepository.save(interview); @@ -91,7 +96,7 @@ public InterviewResponse createInterview(Long userId, CreateInterviewRequest req * 이 순서를 따르고, 꼬리질문이 부모 질문 바로 뒤에 삽입되므로 한 면접관의 질문 묶음이 끝나야 * 다음 면접관으로 넘어간다. */ - private InterviewResponse createMultiInterview(Long userId, List requestedIds) { + private InterviewResponse createMultiInterview(Long userId, List requestedIds, Long companyId) { List personaIds = new ArrayList<>(new LinkedHashSet<>(requestedIds)); if (personaIds.size() != requestedIds.size()) { throw BusinessException.unprocessable("같은 면접관을 두 번 지정할 수 없습니다."); @@ -112,6 +117,8 @@ private InterviewResponse createMultiInterview(Long userId, List requested .mode(InterviewMode.MULTI) .status(Status.IN_PROGRESS) .sessionId(UUID.randomUUID().toString()) + .companyId(companyId) + .companySnapshot(companySnapshotOf(companyId)) .build()); List members = new ArrayList<>(); @@ -127,6 +134,11 @@ private InterviewResponse createMultiInterview(Long userId, List requested return InterviewResponse.from(saved, ordered.stream().map(PersonaEntity::getPersonaId).toList()); } + /** 고른 기업을 지금 모습 그대로 복사해 둔다. 공개 기업이 아니면 404다. 고르지 않았으면 비어 있다. */ + private CompanySnapshot companySnapshotOf(Long companyId) { + return companyId == null ? null : companySnapshotFactory.create(companyId); + } + /** * 진행 순서를 정한다. 기술 면접관이 맨 앞이고 나머지는 요청 순서 그대로다. * diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorMultiRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorMultiRequest.java index c44a3357..a704549e 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorMultiRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorMultiRequest.java @@ -1,9 +1,11 @@ package repit.repit_api_server.domain.userdata.question.dto.request; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import repit.repit_api_server.domain.company.dto.CompanySnapshot; import java.util.List; @@ -34,6 +36,9 @@ public class QuestionTailorMultiRequest { private List questions; // 신규 질문 생성의 근거. /profile 결과의 projectSummary를 넘긴다. private ProjectSummary projectSummary; + // 면접을 만들 때 고른 기업. 고르지 않은 면접은 키 자체를 보내지 않는다 — null도 보내지 않는다. + @JsonInclude(JsonInclude.Include.NON_NULL) + private CompanySnapshot company; private String callbackUrl; /** 면접관 한 명. role(직책)이 질문 관점을, style(성향)이 파고들 대상을, tone(어조)이 세기를 정한다. */ diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequest.java index edf2ed00..4d20564b 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/dto/request/QuestionTailorRequest.java @@ -1,9 +1,11 @@ package repit.repit_api_server.domain.userdata.question.dto.request; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import repit.repit_api_server.domain.company.dto.CompanySnapshot; import java.util.List; @@ -20,6 +22,9 @@ public class QuestionTailorRequest { private String userId; private Profile profile; private List questions; + // 면접을 만들 때 고른 기업. 고르지 않은 면접은 키 자체를 보내지 않는다 — null도 보내지 않는다. + @JsonInclude(JsonInclude.Include.NON_NULL) + private CompanySnapshot company; private String callbackUrl; /** 세 축 모두 선택이지만 하나도 없으면 분석 서버가 422로 거부한다. */ 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 f4ecbbb9..0db306d1 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 @@ -230,6 +230,7 @@ private QuestionTailorAcceptedResponse requestSoloTailor(InterviewEntity intervi .userId(String.valueOf(interview.getUserId())) .profile(profile) .questions(drawn.questions().stream().map(this::toRequestQuestion).toList()) + .company(interview.getCompanySnapshot()) .callbackUrl(withTailorId(callbackBaseUrl + CALLBACK_PATH, tailorId)) .build()); } @@ -285,6 +286,7 @@ private QuestionTailorAcceptedResponse requestMultiTailor(InterviewEntity interv .toList()) .questions(techQuestions.stream().map(this::toMultiRequestQuestion).toList()) .projectSummary(toRequestProjectSummary(drawn)) + .company(interview.getCompanySnapshot()) .callbackUrl(withTailorId(callbackBaseUrl + MULTI_CALLBACK_PATH, tailorId)) .build()); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 95fb880d..6e0789ca 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -142,6 +142,13 @@ app: waiting-timeout: 3m # 시간이 지난 준비 건을 훑는 간격. 폴링하지 않는 클라이언트에게도 이 주기로 실패가 나간다. sweep-interval: 30s + company: + # 기업 데이터 패키지(JSON) 위치. Spring 리소스 패턴. 비우면 가져오지 않고, 공개 기업은 기존 DB 그대로다. + # 기업 기능의 배포 스위치이기도 하다. 공개 기업이 없으면 면접에 기업을 고를 수 없어 분석·채팅 서버로 company가 + # 나가지 않는다. 분석·채팅 서버는 모르는 필드를 조용히 버리므로, 두 서버가 company를 받도록 배포된 뒤에 채운다. + # 개발·스테이징: classpath:company-data/sample/*.json + # 운영: 실제 데이터가 준비되면 classpath:company-data/prod/*.json + data-location: ${COMPANY_DATA_LOCATION:} question-pool: # 이 시간이 지나도록 콜백이 오지 않은 사이클 생성 요청은 잃어버린 것으로 보고, 면접을 시작할 때 다시 요청한다. generating-timeout: 5m diff --git a/src/main/resources/company-data/prod/.gitkeep b/src/main/resources/company-data/prod/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/company-data/sample/sample-commerce.json b/src/main/resources/company-data/sample/sample-commerce.json new file mode 100644 index 00000000..630c7ff2 --- /dev/null +++ b/src/main/resources/company-data/sample/sample-commerce.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "code": "sample-commerce", + "displayName": "샘플커머스", + "legalName": "샘플커머스 주식회사", + "aliases": ["샘플마켓", "Sample Commerce"], + "parentCode": null, + "category": "CONSUMER", + "legalSize": null, + "legalSizeSource": null, + "dartCorpCode": null, + "businessRegNo": null, + "dataAsOf": "2026-10-15", + "sources": [ + { + "sourceKey": "about", + "sourceType": "SAMPLE", + "url": "https://example.invalid/sample-commerce/about", + "title": "회사 소개 (샘플)", + "scope": "샘플커머스 전체", + "publishedAt": null, + "collectedAt": "2026-10-15" + } + ], + "facts": [ + { + "kind": "BUSINESS", + "title": "샘플마켓", + "content": "판매자와 구매자를 연결하는 온라인 거래 서비스", + "sourceKey": "about" + }, + { + "kind": "BUSINESS", + "title": "샘플배송", + "content": "주문한 상품을 다음 날까지 배송하는 물류 서비스", + "sourceKey": "about" + }, + { + "kind": "PROBLEM", + "title": "재고 초과 판매", + "content": "샘플마켓 주문 사례에서 여러 구매 요청이 동시에 들어올 때 재고보다 많이 팔리지 않게 막는 문제를 다룸", + "sourceKey": "about" + }, + { + "kind": "PROBLEM", + "title": "배송 상태 지연", + "content": "샘플배송 사례에서 배송 상태 변경이 여러 시스템에 늦게 반영되는 문제를 다룸", + "sourceKey": "about" + }, + { + "kind": "TALENT", + "title": "오너십", + "content": "문제를 스스로 정의하고 결과까지 책임진다", + "sourceKey": "about", + "positiveSignals": ["맡은 문제를 스스로 정의하고 끝까지 해결한 경험을 구체적으로 설명함"], + "concernSignals": ["결정의 책임을 다른 사람이나 지시에 돌리는 모습을 직접 설명함"], + "signalsOrigin": "DERIVED" + }, + { + "kind": "TALENT", + "title": "고객 관점", + "content": "기능보다 고객이 겪는 불편을 먼저 보고 우선순위를 정한다", + "sourceKey": "about", + "positiveSignals": ["사용자 불편을 근거로 우선순위를 정한 경험을 설명함"], + "concernSignals": ["사용자 영향을 고려하지 않고 결정한 과정을 직접 설명함"], + "signalsOrigin": "DERIVED" + } + ], + "interviewerBrief": "샘플커머스는 판매자와 구매자를 연결하는 거래 서비스 샘플마켓과 다음 날 배송 서비스 샘플배송을 운영하는 가상의 기업이다. 공개 사례로는 동시 주문 시 재고 초과 판매를 막는 문제와 배송 상태가 여러 시스템에 늦게 반영되는 문제를 다뤘다. 인재상은 문제를 스스로 정의하고 결과까지 책임지는 오너십, 고객이 겪는 불편을 먼저 보고 우선순위를 정하는 고객 관점이다.", + "review": { "reviewedBy": null, "reviewedAt": null } +} diff --git a/src/main/resources/company-data/sample/sample-games.json b/src/main/resources/company-data/sample/sample-games.json new file mode 100644 index 00000000..0849fd63 --- /dev/null +++ b/src/main/resources/company-data/sample/sample-games.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "code": "sample-games", + "displayName": "샘플게임즈", + "legalName": "샘플게임즈 주식회사", + "aliases": ["Sample Games"], + "parentCode": null, + "category": "GAME", + "legalSize": null, + "legalSizeSource": null, + "dartCorpCode": null, + "businessRegNo": null, + "dataAsOf": "2026-10-15", + "sources": [ + { + "sourceKey": "about", + "sourceType": "SAMPLE", + "url": "https://example.invalid/sample-games/about", + "title": "회사 소개 (샘플)", + "scope": "샘플게임즈 전체", + "publishedAt": null, + "collectedAt": "2026-10-15" + } + ], + "facts": [ + { + "kind": "BUSINESS", + "title": "샘플배틀", + "content": "여러 이용자가 실시간으로 대전하는 모바일 게임", + "sourceKey": "about" + }, + { + "kind": "PROBLEM", + "title": "동시 접속 급증", + "content": "샘플배틀 업데이트 사례에서 출시 직후 접속이 몰릴 때 대기열로 서버 과부하를 막는 문제를 다룸", + "sourceKey": "about" + }, + { + "kind": "PROBLEM", + "title": "부정 이용 탐지", + "content": "샘플배틀 사례에서 비정상적인 플레이 기록을 찾아내는 문제를 다룸", + "sourceKey": "about" + }, + { + "kind": "TALENT", + "title": "이용자 경험", + "content": "이용자가 느끼는 재미와 공정함을 기준으로 판단한다", + "sourceKey": "about", + "positiveSignals": ["이용자 반응을 근거로 기능을 고친 경험을 설명함"], + "concernSignals": [], + "signalsOrigin": "DERIVED" + }, + { + "kind": "TALENT", + "title": "협업", + "content": "기획·개발·운영이 같은 목표를 보도록 결정 과정을 공유한다", + "sourceKey": "about", + "positiveSignals": ["다른 직군과 목표를 맞추기 위해 결정 과정을 공유한 경험을 설명함"], + "concernSignals": ["갈등 상황에서 다른 직군의 의견을 무시한 과정을 직접 설명함"], + "signalsOrigin": "DERIVED" + }, + { + "kind": "TECH_STACK", + "title": "실시간 서버", + "content": "샘플배틀 사례에서 실시간 대전 서버와 대기열 시스템을 운영함", + "sourceKey": "about" + } + ], + "interviewerBrief": "샘플게임즈는 여러 이용자가 실시간으로 대전하는 모바일 게임 샘플배틀을 운영하는 가상의 기업이다. 공개 사례로는 업데이트 직후 접속이 몰릴 때 대기열로 서버 과부하를 막는 문제와 비정상적인 플레이 기록을 찾아내는 문제를 다뤘다. 인재상은 이용자가 느끼는 재미와 공정함을 기준으로 판단하는 이용자 경험, 기획·개발·운영이 같은 목표를 보도록 결정 과정을 공유하는 협업이다.", + "review": { "reviewedBy": null, "reviewedAt": null } +} diff --git a/src/main/resources/company-data/sample/sample-pay.json b/src/main/resources/company-data/sample/sample-pay.json new file mode 100644 index 00000000..d80ad40e --- /dev/null +++ b/src/main/resources/company-data/sample/sample-pay.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "code": "sample-pay", + "displayName": "샘플페이", + "legalName": "샘플페이 주식회사", + "aliases": ["Sample Pay"], + "parentCode": null, + "category": "FINTECH", + "legalSize": null, + "legalSizeSource": null, + "dartCorpCode": null, + "businessRegNo": null, + "dataAsOf": "2026-10-15", + "sources": [ + { + "sourceKey": "about", + "sourceType": "SAMPLE", + "url": "https://example.invalid/sample-pay/about", + "title": "회사 소개 (샘플)", + "scope": "샘플페이 전체", + "publishedAt": null, + "collectedAt": "2026-10-15" + } + ], + "facts": [ + { + "kind": "BUSINESS", + "title": "샘플송금", + "content": "개인끼리 계좌 정보 없이 연락처로 돈을 보내는 모바일 송금 서비스", + "sourceKey": "about" + }, + { + "kind": "BUSINESS", + "title": "샘플결제", + "content": "온라인 가맹점에 간편결제를 제공하는 결제 서비스", + "sourceKey": "about" + }, + { + "kind": "PROBLEM", + "title": "중복 결제 방지", + "content": "샘플결제 사례에서 네트워크 재시도로 같은 결제가 두 번 처리되지 않게 하는 문제를 다룸", + "sourceKey": "about" + }, + { + "kind": "TALENT", + "title": "정확성", + "content": "돈이 오가는 기능에서 예외 상황까지 확인한 뒤에 출시한다", + "sourceKey": "about", + "positiveSignals": ["예외 상황을 미리 정의하고 검증한 과정을 설명함"], + "concernSignals": ["검증 없이 배포해 생긴 문제를 대수롭지 않게 설명함"], + "signalsOrigin": "DERIVED" + }, + { + "kind": "TALENT", + "title": "빠른 실행", + "content": "작게 만들어 빨리 검증하고 결과를 보고 다음 단계를 정한다", + "sourceKey": "about", + "positiveSignals": ["작은 단위로 먼저 검증하고 결과에 따라 계획을 바꾼 경험을 설명함"], + "concernSignals": [], + "signalsOrigin": "DERIVED" + }, + { + "kind": "MISSION_VISION", + "title": "쉬운 금융", + "content": "누구나 어렵지 않게 금융 서비스를 쓰도록 만든다", + "sourceKey": "about" + } + ], + "interviewerBrief": "샘플페이는 연락처로 돈을 보내는 모바일 송금 서비스 샘플송금과 온라인 가맹점용 간편결제 샘플결제를 운영하는 가상의 기업이다. 공개 사례로는 네트워크 재시도로 같은 결제가 두 번 처리되지 않게 하는 문제를 다뤘다. 인재상은 예외 상황까지 확인한 뒤 출시하는 정확성, 작게 만들어 빨리 검증하는 빠른 실행이다. 미션은 누구나 어렵지 않게 금융 서비스를 쓰도록 만드는 것이다.", + "review": { "reviewedBy": null, "reviewedAt": null } +} diff --git a/src/main/resources/db/migration/V34__add_company.sql b/src/main/resources/db/migration/V34__add_company.sql new file mode 100644 index 00000000..f35c98c4 --- /dev/null +++ b/src/main/resources/db/migration/V34__add_company.sql @@ -0,0 +1,57 @@ +-- 기업 맞춤 면접. 면접을 만들 때 기업 하나를 고르고, 그 기업 정보를 질문 재작성·꼬리질문·피드백에 싣는다. +-- +-- 데이터는 시드 마이그레이션으로 넣지 않는다. 기동할 때 기업 데이터 패키지(JSON)를 읽어 반영한다(CompanyDataImporter). +-- 기업 행은 지우지 않는다. 면접이 company_id로 가리키고 있어서, 패키지에서 빠진 기업은 HIDDEN으로만 바꾼다. +CREATE TABLE company ( + company_id BIGSERIAL PRIMARY KEY, + code VARCHAR(40) NOT NULL UNIQUE, + display_name VARCHAR(30) NOT NULL, + legal_name VARCHAR(100) NOT NULL, + aliases JSONB NOT NULL DEFAULT '[]', + parent_company_id BIGINT REFERENCES company(company_id), + category VARCHAR(20) NOT NULL, + legal_size VARCHAR(10), -- null = 미확인 + legal_size_source TEXT, + dart_corp_code VARCHAR(8) UNIQUE, + business_reg_no VARCHAR(10) UNIQUE, + status VARCHAR(10) NOT NULL DEFAULT 'PUBLISHED', + interviewer_brief TEXT NOT NULL, + data_as_of DATE NOT NULL, + reviewed_by VARCHAR(50), + reviewed_at DATE, + content_hash VARCHAR(64) NOT NULL, -- 가져온 패키지 파일의 SHA-256 + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE TABLE company_source ( + source_id BIGSERIAL PRIMARY KEY, + company_id BIGINT NOT NULL REFERENCES company(company_id), + source_key VARCHAR(40) NOT NULL, + source_type VARCHAR(20) NOT NULL, + url TEXT NOT NULL, + title VARCHAR(200), + scope VARCHAR(100), + published_at DATE, + collected_at DATE NOT NULL, + UNIQUE (company_id, source_key) +); + +CREATE TABLE company_fact ( + fact_id BIGSERIAL PRIMARY KEY, + company_id BIGINT NOT NULL REFERENCES company(company_id), + kind VARCHAR(20) NOT NULL, + title VARCHAR(20) NOT NULL, + content VARCHAR(200) NOT NULL, + positive_signals JSONB NOT NULL DEFAULT '[]', + concern_signals JSONB NOT NULL DEFAULT '[]', + signals_origin VARCHAR(10), + source_id BIGINT NOT NULL REFERENCES company_source(source_id), + sort_order INT NOT NULL +); +CREATE INDEX idx_company_fact_company ON company_fact(company_id, sort_order); + +-- 면접은 만들 때의 기업 정보를 스냅샷으로 들고 있다. 이후 모든 경로는 스냅샷만 읽는다. +ALTER TABLE interview ADD COLUMN company_id BIGINT REFERENCES company(company_id); +ALTER TABLE interview ADD COLUMN company_snapshot JSONB; +ALTER TABLE feedback ADD COLUMN company_fit JSONB; diff --git a/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterIsolationTest.java b/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterIsolationTest.java new file mode 100644 index 00000000..8a155642 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterIsolationTest.java @@ -0,0 +1,165 @@ +package repit.repit_api_server.domain.company.importer; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import repit.repit_api_server.domain.company.entity.CompanyEntity; +import repit.repit_api_server.domain.company.entity.enums.CompanyStatus; +import repit.repit_api_server.domain.company.importer.CompanyDataImporter.Result; +import repit.repit_api_server.domain.company.repository.CompanyRepository; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 가져오기를 실제 트랜잭션 경계 그대로 돌린다(04 §3-4 "기업 하나 = 트랜잭션 하나"). + * + *

테스트 트랜잭션으로 감싸면 가져오기가 그 트랜잭션에 합류해, 한 기업의 DB 오류가 트랜잭션 전체를 망가뜨린다. 그래서 + * 감싸지 않는다. 가져오기는 이번 파일에 없는 공개 기업을 숨기므로, 시작할 때 공개였던 기업은 끝나고 다시 공개로 돌리고 + * 이 테스트가 만든 기업은 지운다. + */ +@SpringBootTest +class CompanyDataImporterIsolationTest { + + private static final JsonMapper MAPPER = JsonMapper.shared(); + + @Autowired + private CompanyDataImporter importer; + @Autowired + private CompanyRepository companyRepository; + @Autowired + private JdbcTemplate jdbcTemplate; + + @TempDir + private Path dir; + + // 다른 데이터와 겹치지 않는 code 접두어와 DART 고유번호. + private final String prefix = "iso-" + UUID.randomUUID().toString().substring(0, 8); + private final String dartCorpCode = String.format("%08d", ThreadLocalRandom.current().nextInt(100_000_000)); + private List publishedBefore; + + @BeforeEach + void rememberPublished() { + publishedBefore = companyRepository.findAllByStatus(CompanyStatus.PUBLISHED).stream() + .map(CompanyEntity::getCompanyId) + .toList(); + } + + @AfterEach + void cleanUp() { + String like = prefix + "-%"; + jdbcTemplate.update("delete from company_fact where company_id in (select company_id from company where code like ?)", like); + jdbcTemplate.update("delete from company_source where company_id in (select company_id from company where code like ?)", like); + jdbcTemplate.update("update company set parent_company_id = null where code like ?", like); + jdbcTemplate.update("delete from company where code like ?", like); + publishedBefore.forEach(id -> jdbcTemplate.update("update company set status = 'PUBLISHED' where company_id = ?", id)); + } + + private String code(String name) { + return prefix + "-" + name; + } + + /** 샘플을 바탕으로 이 테스트의 기업 패키지를 쓴다. 파일 이름은 code와 같아야 한다. */ + private void writePackage(String name, String dart, Consumer change) throws IOException { + ObjectNode node; + try (InputStream in = new ClassPathResource("company-data/sample/sample-commerce.json").getInputStream()) { + node = (ObjectNode) MAPPER.readTree(in); + } + node.put("code", code(name)); + node.put("displayName", "격리 " + name); + node.putArray("aliases"); + if (dart == null) { + node.putNull("dartCorpCode"); + } else { + node.put("dartCorpCode", dart); + } + change.accept(node); + Files.writeString(dir.resolve(code(name) + ".json"), MAPPER.writeValueAsString(node)); + } + + private void writePackage(String name, String dart) throws IOException { + writePackage(name, dart, node -> { }); + } + + private Result importAll() throws IOException { + return importer.importFrom("file:" + dir.toAbsolutePath() + "/*.json"); + } + + private CompanyEntity company(String name) { + return companyRepository.findByCode(code(name)).orElseThrow(); + } + + /** 이 묶음 안에서 DART 고유번호를 b에서 a로 옮겼다. a가 먼저 처리돼 처음엔 막혀도, b를 반영한 뒤 다시 해 들어간다. */ + @Test + void 묶음_안에서_옮긴_식별자는_처리_순서와_상관없이_반영한다() throws IOException { + writePackage("a", null); + writePackage("b", dartCorpCode); + assertThat(importAll().created()).isEqualTo(2); + + writePackage("a", dartCorpCode); + writePackage("b", null); + Result result = importAll(); + + assertThat(result.updated()).isEqualTo(2); + assertThat(result.skipped()).isZero(); + assertThat(company("a").getDartCorpCode()).isEqualTo(dartCorpCode); + assertThat(company("b").getDartCorpCode()).isNull(); + } + + /** 같은 법인을 다른 code로 다시 냈다. 숨긴 옛 기업이 식별자를 놓아 새 기업이 들어간다. */ + @Test + void 숨긴_기업이_들고_있던_식별자는_새_기업이_받는다() throws IOException { + writePackage("old", dartCorpCode); + importAll(); + + Files.delete(dir.resolve(code("old") + ".json")); + writePackage("new", dartCorpCode); + Result result = importAll(); + + assertThat(result.created()).isEqualTo(1); + assertThat(result.hidden()).isEqualTo(1); + assertThat(result.skipped()).isZero(); + assertThat(company("new").getDartCorpCode()).isEqualTo(dartCorpCode); + assertThat(company("old").getStatus()).isEqualTo(CompanyStatus.HIDDEN); + assertThat(company("old").getDartCorpCode()).isNull(); + } + + /** + * 한 기업이 저장 단계에서 실패해도 그 기업만 롤백하고 나머지는 반영한다. b는 공개 기업 x가 아직 들고 있는 DART 고유번호를 + * 요구해 두 번 다 UNIQUE에 걸린다(x의 파일은 이번에 검증에 실패해 x는 그대로 공개로 남는다). + */ + @Test + void 한_기업이_DB에서_실패해도_그_기업만_건너뛰고_나머지는_반영한다() throws IOException { + writePackage("x", dartCorpCode); + importAll(); + + writePackage("x", dartCorpCode, node -> node.put("interviewerBrief", "합격 비결을 알려주는 기업")); + writePackage("a", null); + writePackage("b", dartCorpCode); + writePackage("c", null); + Result result = importAll(); + + assertThat(result.created()).isEqualTo(2); + assertThat(result.skipped()).isEqualTo(2); + assertThat(company("a").getStatus()).isEqualTo(CompanyStatus.PUBLISHED); + assertThat(company("c").getStatus()).isEqualTo(CompanyStatus.PUBLISHED); + assertThat(companyRepository.findByCode(code("b"))).isEmpty(); + assertThat(company("x").getStatus()).isEqualTo(CompanyStatus.PUBLISHED); + assertThat(company("x").getDartCorpCode()).isEqualTo(dartCorpCode); + } +} 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 new file mode 100644 index 00000000..31a3e59a --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataImporterTest.java @@ -0,0 +1,234 @@ +package repit.repit_api_server.domain.company.importer; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.transaction.annotation.Transactional; +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.CompanyStatus; +import repit.repit_api_server.domain.company.importer.CompanyDataImporter.Result; +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.CompanyQueryService; + +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.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 기업 데이터 가져오기를 실제 스키마에 대고 확인한다(04 §3-4). + * + *

파일을 고치고 지우는 경우를 보려고 실제로 배포되는 샘플 패키지(company-data/sample)를 임시 폴더에 복사해 그 위치를 넘긴다. + * 복사본을 따로 두지 않는다 — 두면 샘플을 고쳐도 이 테스트는 옛 복사본으로 통과한다. + * 테스트 트랜잭션은 끝나면 롤백되고, 기업별 트랜잭션은 그 안에 합류한다. + */ +@SpringBootTest +@Transactional +class CompanyDataImporterTest { + + private static final List SAMPLES = List.of("sample-commerce.json", "sample-games.json", "sample-pay.json"); + + @Autowired + private CompanyDataImporter importer; + @Autowired + private CompanyRepository companyRepository; + @Autowired + private CompanyFactRepository companyFactRepository; + @Autowired + private CompanySourceRepository companySourceRepository; + @Autowired + private CompanyQueryService companyQueryService; + + @PersistenceContext + private EntityManager entityManager; + + @TempDir + private Path dir; + + @BeforeEach + void setUp() throws IOException { + // 로컬 DB에는 기동할 때 가져온 기업이 있을 수 있다. 테스트 트랜잭션 안에서 비우고, 끝나면 롤백된다. + entityManager.createNativeQuery("update interview set company_id = null where company_id is not null") + .executeUpdate(); + entityManager.createNativeQuery("delete from company_fact").executeUpdate(); + entityManager.createNativeQuery("delete from company_source").executeUpdate(); + entityManager.createNativeQuery("update company set parent_company_id = null").executeUpdate(); + entityManager.createNativeQuery("delete from company").executeUpdate(); + + for (String fileName : SAMPLES) { + copy(fileName, dir.resolve(fileName)); + } + } + + private static void copy(String sample, Path target) throws IOException { + Files.createDirectories(target.getParent()); + try (InputStream in = new ClassPathResource("company-data/sample/" + sample).getInputStream()) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** 샘플 파일 한 군데를 바꿔 쓴다. 바꿀 문자열이 없으면 테스트가 엉뚱한 것을 확인하므로 바로 실패시킨다. */ + private void rewrite(String fileName, String from, String to) throws IOException { + Path file = dir.resolve(fileName); + String content = Files.readString(file); + assertThat(content).contains(from); + Files.writeString(file, content.replace(from, to)); + } + + private String location() { + return "file:" + dir.toAbsolutePath() + "/*.json"; + } + + private CompanyEntity company(String code) { + return companyRepository.findByCode(code).orElseThrow(); + } + + private List factIds(String code) { + return companyFactRepository.findAllByCompanyIdOrderBySortOrder(company(code).getCompanyId()).stream() + .map(CompanyFactEntity::getFactId) + .toList(); + } + + @Test + void 샘플_세_건을_새로_반영한다() throws IOException { + assertThat(importer.importFrom(location())).isEqualTo(new Result(3, 0, 0, 0, 0)); + + // jsonb·날짜 컬럼까지 실제로 들어갔는지 DB에서 다시 읽는다. + entityManager.flush(); + entityManager.clear(); + + CompanyEntity commerce = company("sample-commerce"); + assertThat(commerce.getStatus()).isEqualTo(CompanyStatus.PUBLISHED); + assertThat(commerce.getDisplayName()).isEqualTo("샘플커머스"); + assertThat(commerce.getCategory()).isEqualTo(CompanyCategory.CONSUMER); + assertThat(commerce.getAliases()).containsExactly("샘플마켓", "Sample Commerce"); + assertThat(commerce.getLegalSize()).isNull(); + assertThat(commerce.getContentHash()).hasSize(64); + + List sources = companySourceRepository.findAll().stream() + .filter(source -> source.getCompanyId().equals(commerce.getCompanyId())) + .toList(); + assertThat(sources).singleElement().extracting(CompanySourceEntity::getSourceKey).isEqualTo("about"); + + List facts = companyFactRepository.findAllByCompanyIdOrderBySortOrder(commerce.getCompanyId()); + assertThat(facts).extracting(CompanyFactEntity::getTitle) + .containsExactly("샘플마켓", "샘플배송", "재고 초과 판매", "배송 상태 지연", "오너십", "고객 관점"); + assertThat(facts).extracting(CompanyFactEntity::getSortOrder).containsExactly(0, 1, 2, 3, 4, 5); + assertThat(facts).allMatch(fact -> fact.getSourceId().equals(sources.getFirst().getSourceId())); + assertThat(facts.get(4).getPositiveSignals()) + .containsExactly("맡은 문제를 스스로 정의하고 끝까지 해결한 경험을 구체적으로 설명함"); + assertThat(facts.getFirst().getPositiveSignals()).isEmpty(); + } + + @Test + void 같은_파일로_다시_가져오면_모두_그대로다() throws IOException { + importer.importFrom(location()); + + assertThat(importer.importFrom(location())).isEqualTo(new Result(0, 0, 3, 0, 0)); + } + + @Test + void 내용이_바뀐_기업만_갱신하고_fact를_다시_넣는다() throws IOException { + importer.importFrom(location()); + List gamesBefore = factIds("sample-games"); + List payBefore = factIds("sample-pay"); + + rewrite("sample-games.json", "모바일 게임\"", "모바일 대전 게임\""); + + assertThat(importer.importFrom(location())).isEqualTo(new Result(0, 1, 2, 0, 0)); + assertThat(factIds("sample-games")).hasSameSizeAs(gamesBefore).doesNotContainAnyElementsOf(gamesBefore); + assertThat(factIds("sample-pay")).isEqualTo(payBefore); + assertThat(companyFactRepository.findAllByCompanyIdOrderBySortOrder(company("sample-games").getCompanyId()) + .getFirst().getContent()).isEqualTo("여러 이용자가 실시간으로 대전하는 모바일 대전 게임"); + } + + @Test + void 파일이_빠진_기업은_숨기고_돌아오면_다시_공개한다() throws IOException { + importer.importFrom(location()); + Files.delete(dir.resolve("sample-games.json")); + + assertThat(importer.importFrom(location())).isEqualTo(new Result(0, 0, 2, 1, 0)); + assertThat(company("sample-games").getStatus()).isEqualTo(CompanyStatus.HIDDEN); + + 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); + } + + @Test + void 검증에_실패한_파일은_건너뛰고_기존_상태를_둔다() throws IOException { + importer.importFrom(location()); + String payHash = company("sample-pay").getContentHash(); + + // 규칙 7 위반(금지 표현)과 깨진 JSON. + rewrite("sample-pay.json", "\"interviewerBrief\": \"샘플페이는", "\"interviewerBrief\": \"합격을 돕는 샘플페이는"); + Files.writeString(dir.resolve("sample-games.json"), "{ 깨진 JSON"); + + assertThat(importer.importFrom(location())).isEqualTo(new Result(0, 0, 1, 0, 2)); + assertThat(company("sample-pay").getStatus()).isEqualTo(CompanyStatus.PUBLISHED); + assertThat(company("sample-pay").getContentHash()).isEqualTo(payHash); + assertThat(company("sample-games").getStatus()).isEqualTo(CompanyStatus.PUBLISHED); + } + + @Test + void code가_겹치는_두_파일은_둘_다_건너뛴다() throws IOException { + Files.delete(dir.resolve("sample-pay.json")); + copy("sample-pay.json", dir.resolve("a/sample-pay.json")); + copy("sample-pay.json", dir.resolve("b/sample-pay.json")); + + assertThat(importer.importFrom("file:" + dir.toAbsolutePath() + "/**/*.json")) + .isEqualTo(new Result(2, 0, 0, 0, 2)); + assertThat(companyRepository.findByCode("sample-pay")).isEmpty(); + } + + @Test + void 위치를_비우거나_파일이_없으면_아무것도_하지_않는다() throws IOException { + importer.importFrom(location()); + Path empty = Files.createDirectory(dir.resolve("empty")); + + assertThat(importer.importFrom("")).isEqualTo(Result.NONE); + assertThat(importer.importFrom(null)).isEqualTo(Result.NONE); + // 위치를 잘못 적었을 수 있다. 숨기지도 않는다. + assertThat(importer.importFrom("file:" + empty.toAbsolutePath() + "/*.json")).isEqualTo(Result.NONE); + assertThat(companyRepository.findAllByStatus(CompanyStatus.PUBLISHED)).hasSize(3); + } + + private CompanyEntity parentOf(String code) { + entityManager.flush(); + entityManager.clear(); + CompanyEntity parent = company(code).getParent(); + return parent == null ? null : company(parent.getCode()); + } + + /** 모회사 파일이 잠깐 틀렸다고 자회사 연결까지 끊지 않는다. 모회사가 묶음에서 아예 빠지면 그때 끊는다. */ + @Test + void 모회사_파일이_실패하면_연결을_두고_빠지면_끊는다() throws IOException { + rewrite("sample-commerce.json", "\"parentCode\": null", "\"parentCode\": \"sample-pay\""); + importer.importFrom(location()); + assertThat(parentOf("sample-commerce").getCode()).isEqualTo("sample-pay"); + // 목록은 모회사를 함께 읽어 이름을 싣는다(findWithParentByStatus). + assertThat(companyQueryService.search("샘플커머스", null).getFirst().parentDisplayName()).isEqualTo("샘플페이"); + + rewrite("sample-pay.json", "\"interviewerBrief\": \"샘플페이는", "\"interviewerBrief\": \"합격을 돕는 샘플페이는"); + importer.importFrom(location()); + assertThat(parentOf("sample-commerce").getCode()).isEqualTo("sample-pay"); + + Files.delete(dir.resolve("sample-pay.json")); + importer.importFrom(location()); + assertThat(parentOf("sample-commerce")).isNull(); + } +} 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 new file mode 100644 index 00000000..b069ae45 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/company/importer/CompanyDataValidatorTest.java @@ -0,0 +1,192 @@ +package repit.repit_api_server.domain.company.importer; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 패키지 파일 하나에 적용하는 규칙(01 §3 1~7, 9). 샘플을 하나씩만 어겨 그 규칙이 걸리는지 본다. */ +class CompanyDataValidatorTest { + + private static final JsonMapper MAPPER = JsonMapper.shared(); + + private static ObjectNode read(String fileName) { + try (InputStream in = new ClassPathResource("company-data/sample/" + fileName).getInputStream()) { + return (ObjectNode) MAPPER.readTree(in); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private static List validate(String fileName, ObjectNode node) { + return CompanyDataValidator.validate(fileName, MAPPER.treeToValue(node, CompanyDataPackage.class)); + } + + /** 샘플커머스를 한 군데만 바꿔 검사한다. */ + private static List violate(Consumer change) { + ObjectNode node = read("sample-commerce.json"); + change.accept(node); + return validate("sample-commerce.json", node); + } + + private static ArrayNode facts(ObjectNode node) { + return (ArrayNode) node.get("facts"); + } + + private static ObjectNode fact(ObjectNode node, int index) { + return (ObjectNode) facts(node).get(index); + } + + private static void removeFacts(ObjectNode node, String kind) { + ArrayNode facts = facts(node); + for (int i = facts.size() - 1; i >= 0; i--) { + if (kind.equals(facts.get(i).get("kind").stringValue())) { + facts.remove(i); + } + } + } + + @Test + void 샘플_세_개는_오류가_없다() { + for (String fileName : List.of("sample-commerce.json", "sample-pay.json", "sample-games.json")) { + assertThat(validate(fileName, read(fileName))).as(fileName).isEmpty(); + } + } + + // 규칙 1 — 형식 + + @Test + void schemaVersion이_1이_아니면_다른_규칙은_보지_않는다() { + assertThat(violate(node -> node.put("schemaVersion", 2))).singleElement() + .asString().contains("schemaVersion"); + } + + @Test + void code_형식이_틀리면_오류다() { + assertThat(violate(node -> node.put("code", "Sample_Commerce"))).anyMatch(e -> e.contains("code는")); + } + + @Test + void 필수_필드가_없으면_오류다() { + assertThat(violate(node -> node.remove("interviewerBrief"))).anyMatch(e -> e.startsWith("interviewerBrief")); + assertThat(violate(node -> node.putNull("dataAsOf"))).anyMatch(e -> e.startsWith("dataAsOf")); + } + + @Test + void 길이_상한을_넘으면_오류다() { + assertThat(violate(node -> node.put("displayName", "가".repeat(31)))).anyMatch(e -> e.startsWith("displayName")); + assertThat(violate(node -> fact(node, 0).put("title", "가".repeat(21)))).anyMatch(e -> e.startsWith("facts[0].title")); + assertThat(violate(node -> fact(node, 0).put("content", "가".repeat(201)))) + .anyMatch(e -> e.startsWith("facts[0].content")); + assertThat(violate(node -> node.put("interviewerBrief", "가".repeat(601)))) + .anyMatch(e -> e.startsWith("interviewerBrief")); + } + + @Test + void 개수_상한을_넘으면_오류다() { + assertThat(violate(node -> { + for (int i = 0; i < 15; i++) { + facts(node).add(fact(node, 0).deepCopy()); + } + })).anyMatch(e -> e.startsWith("facts는")); + assertThat(violate(node -> ((ArrayNode) fact(node, 4).get("positiveSignals")) + .add("하나").add("둘").add("셋"))).anyMatch(e -> e.startsWith("facts[4].positiveSignals는")); + } + + @Test + void 출처_주소가_https가_아니면_오류다() { + assertThat(violate(node -> ((ObjectNode) node.get("sources").get(0)).put("url", "http://example.invalid/a"))) + .anyMatch(e -> e.startsWith("sources[0].url")); + } + + // 규칙 2 — 파일 이름 + + @Test + void 파일_이름이_code와_다르면_오류다() { + assertThat(validate("other.json", read("sample-commerce.json"))).anyMatch(e -> e.startsWith("파일 이름")); + } + + // 규칙 3 — 공개 조건 + + @Test + void 인재상이나_주력_사업이_없으면_오류다() { + assertThat(violate(node -> removeFacts(node, "TALENT"))).anyMatch(e -> e.startsWith("TALENT")); + assertThat(violate(node -> removeFacts(node, "BUSINESS"))).anyMatch(e -> e.startsWith("BUSINESS")); + } + + // 규칙 4 — 출처 연결 + + @Test + void fact가_없는_출처를_가리키면_오류다() { + assertThat(violate(node -> fact(node, 0).put("sourceKey", "missing"))) + .anyMatch(e -> e.startsWith("facts[0].sourceKey가")); + } + + @Test + void 출처_키가_겹치면_오류다() { + assertThat(violate(node -> ((ArrayNode) node.get("sources")).add(node.get("sources").get(0).deepCopy()))) + .anyMatch(e -> e.startsWith("sourceKey가 겹칩니다")); + } + + // 규칙 5 — 신호 + + @Test + void 인재상이_아닌_fact에_신호가_있으면_오류다() { + assertThat(violate(node -> fact(node, 0).putArray("positiveSignals").add("신호"))) + .anyMatch(e -> e.contains("TALENT가 아니라")); + } + + @Test + void 신호가_있는데_출처_구분이_없으면_오류다() { + assertThat(violate(node -> fact(node, 4).putNull("signalsOrigin"))).anyMatch(e -> e.contains("signalsOrigin")); + } + + // 규칙 6 — 규모 + + @Test + void 규모가_있는데_판정_근거가_없으면_오류다() { + assertThat(violate(node -> node.put("legalSize", "LARGE"))).anyMatch(e -> e.startsWith("legalSize")); + } + + // 규칙 7 — 금지 표현 + + @Test + void 금지_표현이_있으면_오류다() { + assertThat(violate(node -> fact(node, 0).put("content", "저희가 운영하는 서비스"))) + .anyMatch(e -> e.contains("\"저희\"")); + assertThat(violate(node -> node.put("interviewerBrief", "공식 면접 정보"))) + .anyMatch(e -> e.contains("\"공식\"")); + } + + // 규칙 9 — 검수 + + @Test + void 실제_출처면_검수_기록이_있어야_한다() { + Consumer real = node -> ((ObjectNode) node.get("sources").get(0)).put("sourceType", "CAREER_PAGE"); + + assertThat(violate(real)).anyMatch(e -> e.contains("review")); + assertThat(violate(real.andThen(node -> node.putObject("review") + .put("reviewedBy", "검수자").put("reviewedAt", "2026-10-15")))).isEmpty(); + } + + @Test + void 자기_자신을_모회사로_두면_오류다() { + assertThat(violate(node -> node.put("parentCode", "sample-commerce"))).anyMatch(e -> e.startsWith("parentCode")); + } + + @Test + void 검수자_이름이_50자를_넘으면_오류다() { + assertThat(violate(node -> node.putObject("review").put("reviewedBy", "가".repeat(51)).put("reviewedAt", "2026-10-15"))) + .anyMatch(e -> e.startsWith("review.reviewedBy")); + assertThat(violate(node -> node.putObject("review").put("reviewedBy", "가".repeat(50)).put("reviewedAt", "2026-10-15"))) + .isEmpty(); + } +} 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 new file mode 100644 index 00000000..176d1120 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/company/service/CompanyQueryServiceTest.java @@ -0,0 +1,158 @@ +package repit.repit_api_server.domain.company.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +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.enums.CompanyCategory; +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.exception.BusinessException; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +/** 공개 기업 목록·검색(04 §5). */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CompanyQueryServiceTest { + + @Mock + private CompanyRepository companyRepository; + @Mock + private CompanyFactRepository companyFactRepository; + + private CompanyQueryService service; + + @BeforeEach + void setUp() { + service = new CompanyQueryService(companyRepository, companyFactRepository); + + CompanyEntity commerce = company(3L, "샘플커머스", CompanyCategory.CONSUMER, List.of("샘플마켓", "Sample Commerce")); + CompanyEntity games = company(4L, "샘플게임즈", CompanyCategory.GAME, List.of("Sample Games")); + CompanyEntity pay = company(5L, "샘플페이", CompanyCategory.FINTECH, List.of("Sample Pay")); + CompanyEntity otherPay = company(6L, "가나페이", CompanyCategory.FINTECH, List.of()); + otherPay.setParent(pay); + when(companyRepository.findWithParentByStatus(CompanyStatus.PUBLISHED)) + .thenReturn(new ArrayList<>(List.of(commerce, games, pay, otherPay))); + + when(companyFactRepository.findAllByKindOrderBySortOrder(CompanyFactKind.TALENT)).thenReturn(List.of( + talent(3L, "오너십"), talent(3L, "고객 관점"), talent(3L, "집요함"), talent(3L, "네 번째"), + talent(5L, "정확성"))); + } + + private CompanyEntity company(Long id, String displayName, CompanyCategory category, List aliases) { + return CompanyEntity.builder() + .companyId(id) + .code("code-" + id) + .displayName(displayName) + .legalName(displayName + " 주식회사") + .aliases(aliases) + .category(category) + .status(CompanyStatus.PUBLISHED) + .dataAsOf(LocalDate.parse("2026-10-15")) + .build(); + } + + private CompanyFactEntity talent(Long companyId, String title) { + return CompanyFactEntity.builder().companyId(companyId).kind(CompanyFactKind.TALENT).title(title).build(); + } + + private List names(List companies) { + return companies.stream().map(CompanySummaryResponse::displayName).toList(); + } + + @Test + void 분류_순서_다음_이름_가나다순으로_정렬한다() { + assertThat(names(service.search(null, null))).containsExactly("가나페이", "샘플페이", "샘플게임즈", "샘플커머스"); + } + + @Test + void 별칭으로도_찾는다() { + assertThat(names(service.search("샘플마켓", null))).containsExactly("샘플커머스"); + } + + @Test + void 공백과_대소문자는_보지_않는다() { + assertThat(names(service.search(" sample COMMERCE ", null))).containsExactly("샘플커머스"); + assertThat(names(service.search("샘플 커머스", null))).containsExactly("샘플커머스"); + // 법인 이름도 찾는다. + assertThat(names(service.search("게임즈주식", null))).containsExactly("샘플게임즈"); + } + + @Test + void 분류로_거른다() { + assertThat(names(service.search(null, CompanyCategory.FINTECH))).containsExactly("가나페이", "샘플페이"); + assertThat(names(service.search("샘플", CompanyCategory.FINTECH))).containsExactly("샘플페이"); + } + + @Test + void 인재상_키워드는_title을_순서대로_세_개까지_담는다() { + CompanySummaryResponse commerce = service.search("샘플커머스", null).getFirst(); + + assertThat(commerce.talentKeywords()).containsExactly("오너십", "고객 관점", "집요함"); + assertThat(service.search("샘플게임즈", null).getFirst().talentKeywords()).isEmpty(); + } + + @Test + void 확인하지_못한_규모는_null_그대로_내보낸다() { + CompanySummaryResponse commerce = service.search("샘플커머스", null).getFirst(); + + assertThat(commerce.legalSize()).isNull(); + assertThat(commerce.legalName()).isEqualTo("샘플커머스 주식회사"); + assertThat(commerce.parentDisplayName()).isNull(); + assertThat(commerce.dataAsOf()).isEqualTo(LocalDate.parse("2026-10-15")); + assertThat(service.search("가나페이", null).getFirst().parentDisplayName()).isEqualTo("샘플페이"); + } + + @Test + void 공개_기업이_없으면_빈_목록이다() { + when(companyRepository.findWithParentByStatus(CompanyStatus.PUBLISHED)).thenReturn(List.of()); + + assertThat(service.search(null, null)).isEmpty(); + } + + @Test + void 검색어가_너무_짧거나_길면_422다() { + for (String q : List.of("샘", "가".repeat(31))) { + assertThatThrownBy(() -> service.search(q, null)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getStatus()) + .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + } + // 빈 검색어는 검색어가 없는 것과 같다. + assertThat(service.search(" ", null)).hasSize(4); + } + + /** 다른 곳에서 붙여 넣은 검색어에는 NBSP(U+00A0)·전각 공백(U+3000)이 섞여 온다. */ + @Test + void 유니코드_공백도_보지_않는다() { + assertThat(names(service.search("샘플\u00A0커머스", null))).containsExactly("샘플커머스"); + assertThat(names(service.search("샘플\u3000커머스", null))).containsExactly("샘플커머스"); + } + + @Test + void 숨긴_모회사의_이름은_내보내지_않는다() { + CompanyEntity hiddenParent = company(9L, "숨긴모회사", CompanyCategory.FINTECH, List.of()); + hiddenParent.setStatus(CompanyStatus.HIDDEN); + CompanyEntity child = company(10L, "자회사페이", CompanyCategory.FINTECH, List.of()); + child.setParent(hiddenParent); + when(companyRepository.findWithParentByStatus(CompanyStatus.PUBLISHED)).thenReturn(List.of(child)); + + assertThat(service.search(null, null).getFirst().parentDisplayName()).isNull(); + } +} 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 new file mode 100644 index 00000000..2dc40a58 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/company/service/CompanySnapshotFactoryTest.java @@ -0,0 +1,156 @@ +package repit.repit_api_server.domain.company.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +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.CompanyCategory; +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.entity.enums.LegalSize; +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.exception.BusinessException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +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.Mockito.when; + +/** 면접에 남기는 기업 스냅샷. 분석 서버 계약(00 §6-1)과 같은 모양이어야 그대로 실어 보낼 수 있다. */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CompanySnapshotFactoryTest { + + @Mock + private CompanyRepository companyRepository; + @Mock + private CompanyFactRepository companyFactRepository; + + private CompanySnapshotFactory factory; + + @BeforeEach + void setUp() { + factory = new CompanySnapshotFactory(companyRepository, companyFactRepository); + + when(companyRepository.findByCompanyIdAndStatus(3L, CompanyStatus.PUBLISHED)).thenReturn(Optional.of( + CompanyEntity.builder() + .companyId(3L) + .code("sample-commerce") + .displayName("샘플커머스") + .legalName("샘플커머스 주식회사") + .category(CompanyCategory.CONSUMER) + // 규모가 있어도 스냅샷에는 싣지 않는다. + .legalSize(LegalSize.LARGE) + .status(CompanyStatus.PUBLISHED) + .interviewerBrief("샘플커머스는 가상의 기업이다.") + .build())); + when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L)).thenReturn(List.of( + fact(21L, CompanyFactKind.BUSINESS, "샘플마켓", 0, List.of("잘못 들어간 신호")), + fact(23L, CompanyFactKind.TALENT, "오너십", 1, List.of("끝까지 해결한 경험을 설명함")))); + } + + private CompanyFactEntity fact(Long factId, CompanyFactKind kind, String title, int order, List signals) { + return CompanyFactEntity.builder() + .factId(factId) + .companyId(3L) + .kind(kind) + .title(title) + .content(title + " 설명") + .positiveSignals(signals) + .concernSignals(List.of("우려")) + .signalsOrigin(SignalsOrigin.DERIVED) + .sourceId(1L) + .sortOrder(order) + .build(); + } + + @Test + void 정보는_표시_순서대로_id를_문자열로_담는다() { + CompanySnapshot snapshot = factory.create(3L); + + assertThat(snapshot.companyId()).isEqualTo("3"); + assertThat(snapshot.displayName()).isEqualTo("샘플커머스"); + assertThat(snapshot.category()).isEqualTo("CONSUMER"); + assertThat(snapshot.interviewerBrief()).isEqualTo("샘플커머스는 가상의 기업이다."); + assertThat(snapshot.facts()).extracting(CompanySnapshot.Fact::factId).containsExactly("21", "23"); + assertThat(snapshot.facts()).extracting(CompanySnapshot.Fact::title).containsExactly("샘플마켓", "오너십"); + assertThat(snapshot.facts()).extracting(CompanySnapshot.Fact::kind).containsExactly("BUSINESS", "TALENT"); + } + + @Test + void 인재상이_아닌_정보의_신호는_빈_목록이다() { + CompanySnapshot snapshot = factory.create(3L); + + assertThat(snapshot.facts().getFirst().positiveSignals()).isEmpty(); + assertThat(snapshot.facts().getFirst().concernSignals()).isEmpty(); + assertThat(snapshot.facts().get(1).positiveSignals()).containsExactly("끝까지 해결한 경험을 설명함"); + assertThat(snapshot.facts().get(1).concernSignals()).containsExactly("우려"); + } + + @Test + void 스냅샷_JSON에는_규모와_출처가_없다() { + JsonNode json = JsonMapper.shared().valueToTree(factory.create(3L)); + + assertThat(json.has("legalSize")).isFalse(); + assertThat(json.get("facts").get(1).has("signalsOrigin")).isFalse(); + assertThat(json.get("facts").get(1).has("sourceId")).isFalse(); + // 채팅 서버용 요약 메서드는 속성이 아니다. + assertThat(json.has("chat")).isFalse(); + assertThat(json.get("facts").get(0).get("positiveSignals").isArray()).isTrue(); + } + + @Test + void 공개되지_않았거나_없는_기업이면_404다() { + when(companyRepository.findByCompanyIdAndStatus(4L, CompanyStatus.PUBLISHED)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> factory.create(4L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getStatus()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void 분석_서버_상한을_넘으면_만들지_않는다() { + when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L)) + .thenReturn(List.of(fact(21L, CompanyFactKind.BUSINESS, "가".repeat(21), 0, List.of()))); + + assertThatThrownBy(() -> factory.create(3L)).isInstanceOf(IllegalStateException.class); + } + + /** 분석 서버는 인재상·주력 사업이 하나도 없는 기업 정보를 422로 거부한다. 고정된 스냅샷이라 면접을 영영 못 연다. */ + @Test + void 인재상이나_주력_사업이_없으면_만들지_않는다() { + when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L)) + .thenReturn(List.of(fact(21L, CompanyFactKind.BUSINESS, "샘플마켓", 0, List.of()))); + assertThatThrownBy(() -> factory.create(3L)).isInstanceOf(IllegalStateException.class); + + when(companyFactRepository.findAllByCompanyIdOrderBySortOrder(3L)) + .thenReturn(List.of(fact(23L, CompanyFactKind.TALENT, "오너십", 0, List.of()))); + assertThatThrownBy(() -> factory.create(3L)).isInstanceOf(IllegalStateException.class); + } + + @Test + void 이름이나_요약이_비어_있으면_만들지_않는다() { + when(companyRepository.findByCompanyIdAndStatus(4L, CompanyStatus.PUBLISHED)).thenReturn(Optional.of( + CompanyEntity.builder().companyId(4L).code("blank").displayName("샘플") + .category(CompanyCategory.CONSUMER).status(CompanyStatus.PUBLISHED).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); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackCallbackPersistenceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackCallbackPersistenceTest.java index 6572af71..9d856947 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackCallbackPersistenceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackCallbackPersistenceTest.java @@ -110,7 +110,7 @@ private FeedbackCallbackRequest multiCallback() { "부작용까지는 못 짚었습니다.", null); return new FeedbackCallbackRequest(jobId, sessionId, "succeeded", - new FeedbackCallbackRequest.Result(overall, List.of(tech, hr), List.of(item)), null); + new FeedbackCallbackRequest.Result(overall, List.of(tech, hr), List.of(item), null), null); } /** 쓴 것을 DB에서 다시 읽어오게 한다. 컨텍스트에 남아 있는 것을 그대로 보면 저장 여부를 알 수 없다. */ @@ -219,7 +219,7 @@ private void flushAndClear() { JsonMapper.shared().valueToTree(axisScores)); feedbackService.handleCallback(new FeedbackCallbackRequest(jobId, sessionId, "succeeded", - new FeedbackCallbackRequest.Result(overall, List.of(tech, hr), List.of(item)), null)); + new FeedbackCallbackRequest.Result(overall, List.of(tech, hr), List.of(item), null), null)); flushAndClear(); FeedbackEntity saved = feedbackRepository.findById(accepted.getFeedbackId()).orElseThrow(); @@ -254,7 +254,7 @@ private void flushAndClear() { 70, 70, 70, "요약", List.of(), List.of(), List.of(), 1, 1, JsonMapper.shared().valueToTree(new ScoreBreakdownResponse(longVersion, List.of(), null))); feedbackService.handleCallback(new FeedbackCallbackRequest(jobId, sessionId, "succeeded", - new FeedbackCallbackRequest.Result(overall, null, List.of()), null)); + new FeedbackCallbackRequest.Result(overall, null, List.of(), null), null)); flushAndClear(); FeedbackEntity saved = feedbackRepository.findById(accepted.getFeedbackId()).orElseThrow(); 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 new file mode 100644 index 00000000..357d9e29 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceCompanyTest.java @@ -0,0 +1,230 @@ +package repit.repit_api_server.domain.userdata.feedback.service; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; +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.enums.CompanyCategory; +import repit.repit_api_server.domain.company.entity.enums.CompanyStatus; +import repit.repit_api_server.domain.company.repository.CompanyRepository; +import repit.repit_api_server.domain.userdata.answer.entity.AnswerEntity; +import repit.repit_api_server.domain.userdata.answer.repository.AnswerRepository; +import repit.repit_api_server.domain.userdata.feedback.dto.request.FeedbackCallbackRequest; +import repit.repit_api_server.domain.userdata.feedback.dto.request.FeedbackSoloRequest; +import repit.repit_api_server.domain.userdata.feedback.dto.response.CompanyFitResponse; +import repit.repit_api_server.domain.userdata.feedback.dto.response.FeedbackAcceptedResponse; +import repit.repit_api_server.domain.userdata.feedback.dto.response.FeedbackResponse; +import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackEntity; +import repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackStatus; +import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackRepository; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +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.InterviewRepository; +import repit.repit_api_server.domain.userdata.question.entity.QuestionEntity; +import repit.repit_api_server.domain.userdata.question.entity.enums.Type; +import repit.repit_api_server.domain.userdata.question.repository.QuestionRepository; +import repit.repit_api_server.global.client.AiServerClient; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 기업을 고른 면접의 피드백. 요청에 기업 정보가 실리고, 콜백의 기업 관점 코멘트가 저장·조회되는지 본다. + * + *

면접 스냅샷과 companyFit이 jsonb로 실제 스키마에 들어가는지까지 보려고 DB에 쓴다. 롤백된다. + * 면접의 경로는 스냅샷만 읽지만, 조회 응답의 기업 id는 면접의 company_id 컬럼이라 기업 행을 하나 만든다. + */ +@SpringBootTest +@Transactional +class FeedbackServiceCompanyTest { + + private static final Long USER_ID = 9L; + + private static final CompanySnapshot SNAPSHOT = new CompanySnapshot("3", "샘플커머스", "CONSUMER", + List.of(new CompanySnapshot.Fact("21", "BUSINESS", "샘플마켓", + "판매자와 구매자를 연결하는 온라인 거래 서비스", List.of(), List.of()), + new CompanySnapshot.Fact("23", "TALENT", "오너십", "문제를 스스로 정의하고 결과까지 책임진다", + List.of("맡은 문제를 끝까지 해결한 경험을 설명함"), List.of("책임을 남에게 돌림"))), + "샘플커머스는 가상의 기업이다."); + + @Autowired + private FeedbackService feedbackService; + @Autowired + private FeedbackRepository feedbackRepository; + @Autowired + private InterviewRepository interviewRepository; + @Autowired + private QuestionRepository questionRepository; + @Autowired + private AnswerRepository answerRepository; + @Autowired + private CompanyRepository companyRepository; + + @MockitoBean + private AiServerClient aiServerClient; + + @PersistenceContext + private EntityManager entityManager; + + private final String sessionId = UUID.randomUUID().toString(); + private final String jobId = UUID.randomUUID().toString(); + + private Long givenCompany() { + return companyRepository.save(CompanyEntity.builder() + .code("fit-" + UUID.randomUUID().toString().substring(0, 8)) + .displayName("샘플커머스") + .legalName("샘플커머스 주식회사") + .aliases(List.of()) + .category(CompanyCategory.CONSUMER) + .status(CompanyStatus.PUBLISHED) + .interviewerBrief("샘플커머스는 가상의 기업이다.") + .dataAsOf(LocalDate.parse("2026-10-15")) + .contentHash("0".repeat(64)) + .build()).getCompanyId(); + } + + /** 채점할 것이 있는 끝난 1:1 면접. 스냅샷이 있으면 그 기업을 고른 면접이다. */ + private InterviewEntity givenFinishedInterview(CompanySnapshot snapshot) { + InterviewEntity interview = interviewRepository.save(InterviewEntity.builder() + .userId(USER_ID) + .mode(InterviewMode.SOLO) + .status(Status.COMPLETED) + .sessionId(sessionId) + .companyId(snapshot == null ? null : givenCompany()) + .companySnapshot(snapshot) + .build()); + QuestionEntity question = questionRepository.save(QuestionEntity.builder() + .interviewId(interview.getInterviewId()) + .chatQuestionId(1L) + .type(Type.ORIGINAL) + .intention("기술 선택의 근거") + .content("Redis를 캐시로 두신 이유는?") + .createdAt(LocalDateTime.now()) + .build()); + answerRepository.save(AnswerEntity.builder() + .interviewId(interview.getInterviewId()) + .questionId(question.getQuestionId()) + .userId(USER_ID) + .responseTime(90) + .content("조회가 쓰기보다 많아서요.") + .createdAt(LocalDateTime.now()) + .build()); + return interview; + } + + private FeedbackSoloRequest requested(CompanySnapshot snapshot) { + InterviewEntity interview = givenFinishedInterview(snapshot); + when(aiServerClient.requestSoloFeedback(any())) + .thenReturn(new FeedbackAcceptedResponse(jobId, sessionId, "accepted", null)); + + feedbackService.requestFeedback(USER_ID, interview.getInterviewId()); + + ArgumentCaptor sent = ArgumentCaptor.forClass(FeedbackSoloRequest.class); + verify(aiServerClient).requestSoloFeedback(sent.capture()); + return sent.getValue(); + } + + private FeedbackEntity callback(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)); + entityManager.flush(); + entityManager.clear(); + return feedbackRepository.findByJobId(jobId).orElseThrow(); + } + + private static JsonNode json(String text) { + return JsonMapper.shared().readTree(text); + } + + @Test + void 기업을_고른_면접은_채점_요청에_스냅샷을_그대로_싣는다() { + FeedbackSoloRequest sent = requested(SNAPSHOT); + + assertThat(sent.getCompany()).isEqualTo(SNAPSHOT); + JsonNode company = json(JsonMapper.shared().writeValueAsString(sent)).get("company"); + assertThat(company.get("companyId").stringValue()).isEqualTo("3"); + assertThat(company.get("facts").get(1).get("title").stringValue()).isEqualTo("오너십"); + assertThat(company.has("legalSize")).isFalse(); + } + + @Test + void 기업이_없는_면접은_company_키를_보내지_않는다() { + FeedbackSoloRequest sent = requested(null); + + assertThat(json(JsonMapper.shared().writeValueAsString(sent)).has("company")).isFalse(); + } + + @Test + void 콜백의_기업_관점_코멘트를_저장하고_조회에_싣는다() { + InterviewEntity interview = givenFinishedInterview(SNAPSHOT); + + FeedbackEntity saved = callback(json(""" + { + "comment": "샘플커머스가 중요하게 보는 오너십 관점에서는 원인을 끝까지 추적한 점이 돋보입니다.", + "matchedFocus": [ + { "factId": "23", "kind": "TALENT", "focus": "오너십", + "evidence": "장애 원인을 직접 추적했습니다.", "questionIds": ["901"] } + ], + "gaps": [], + "unknownField": true + }""")); + + assertThat(saved.getStatus()).isEqualTo(FeedbackStatus.SUCCEEDED); + CompanyFitResponse fit = saved.getCompanyFit(); + assertThat(fit.comment()).startsWith("샘플커머스가"); + assertThat(fit.matchedFocus()).singleElement().satisfies(item -> { + assertThat(item.factId()).isEqualTo("23"); + assertThat(item.focus()).isEqualTo("오너십"); + assertThat(item.questionIds()).containsExactly("901"); + }); + assertThat(fit.gaps()).isEmpty(); + + FeedbackResponse response = feedbackService.getFeedback(USER_ID, interview.getInterviewId()); + assertThat(response.getCompany()).isEqualTo(new CompanyBadge(interview.getCompanyId(), "샘플커머스")); + assertThat(response.getCompanyFit()).isEqualTo(fit); + } + + @Test + void 기업_관점_코멘트가_없거나_모양이_틀려도_피드백은_저장한다() { + givenFinishedInterview(SNAPSHOT); + + for (JsonNode companyFit : new JsonNode[]{null, json("null"), json("\"문자열\""), + json("{\"comment\": \"코멘트\", \"matchedFocus\": \"목록이 아님\"}"), json("{\"gaps\": []}")}) { + FeedbackEntity saved = callback(companyFit); + + assertThat(saved.getStatus()).as(String.valueOf(companyFit)).isEqualTo(FeedbackStatus.SUCCEEDED); + assertThat(saved.getTotalScore()).isEqualTo(72); + assertThat(saved.getCompanyFit()).as(String.valueOf(companyFit)).isNull(); + } + } + + @Test + void 기업이_없는_면접의_피드백_조회에는_기업이_비어_있다() { + InterviewEntity interview = givenFinishedInterview(null); + callback(null); + + FeedbackResponse response = feedbackService.getFeedback(USER_ID, interview.getInterviewId()); + + assertThat(response.getCompany()).isNull(); + assertThat(response.getCompanyFit()).isNull(); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceMultiCallbackTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceMultiCallbackTest.java index d36bb4f0..60e990cb 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceMultiCallbackTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceMultiCallbackTest.java @@ -114,7 +114,7 @@ private FeedbackCallbackRequest callback() { List.of("p99 지연을 근거로 든 점"), List.of("무효화 전략 미언급"), "부작용까지는 못 짚었습니다.", null); return new FeedbackCallbackRequest("job-1", "sess-1", "succeeded", - new FeedbackCallbackRequest.Result(overall, List.of(tech, hr, ceo), List.of(item)), null); + new FeedbackCallbackRequest.Result(overall, List.of(tech, hr, ceo), List.of(item), null), null); } @Test @@ -256,7 +256,7 @@ private FeedbackEntity acceptedFeedback() { null, "사실과 대응을 나눠 말한다.", List.of(), List.of(), "답하지 않았습니다.", null); FeedbackCallbackRequest withStray = new FeedbackCallbackRequest("job-1", "sess-1", "succeeded", new FeedbackCallbackRequest.Result(request.getResult().getOverall(), - request.getResult().getPersonas(), List.of(stray)), + request.getResult().getPersonas(), List.of(stray), null), null); service.handleCallback(withStray); @@ -293,7 +293,7 @@ private FeedbackCallbackRequest callbackWith(List saved = ArgumentCaptor.forClass(FeedbackEntity.class); verify(feedbackRepository, org.mockito.Mockito.atLeastOnce()).save(saved.capture()); @@ -423,7 +423,7 @@ private JsonNode json(Object value) { "답변", "모범답변", List.of(), List.of(), "총평", json(axisScores)); service.handleCallback(new FeedbackCallbackRequest("job-1", "sess-1", "succeeded", - new FeedbackCallbackRequest.Result(overall, List.of(tech), List.of(item)), null)); + new FeedbackCallbackRequest.Result(overall, List.of(tech), List.of(item), null), null)); ArgumentCaptor saved = ArgumentCaptor.forClass(FeedbackEntity.class); verify(feedbackRepository, org.mockito.Mockito.atLeastOnce()).save(saved.capture()); @@ -478,7 +478,7 @@ private JsonNode json(Object value) { "답변", "모범답변", List.of(), List.of(), "총평", json(axisScores)); service.handleCallback(new FeedbackCallbackRequest("job-1", "sess-1", "succeeded", - new FeedbackCallbackRequest.Result(overall, List.of(persona(11L, 78)), List.of(item)), null)); + new FeedbackCallbackRequest.Result(overall, List.of(persona(11L, 78)), List.of(item), null), null)); ArgumentCaptor saved = ArgumentCaptor.forClass(FeedbackEntity.class); verify(feedbackRepository, org.mockito.Mockito.atLeastOnce()).save(saved.capture()); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java index b18a809b..9dc67343 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java @@ -1,5 +1,9 @@ package repit.repit_api_server.domain.userdata.interview.service; +import repit.repit_api_server.domain.company.dto.ChatCompany; +import repit.repit_api_server.domain.company.dto.CompanySnapshot; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -454,4 +458,31 @@ private TailoredQuestionResponse question(int id, String content) { verify(chatServerClient, never()).prepareInterview(any()); } + + /** 채팅 서버는 실시간 경로라 기업 요약 두 칸만 받는다. 정보 목록·신호는 보내지 않는다. */ + @Test + void 기업을_고른_면접은_준비_요청에_기업_요약만_싣는다() { + InterviewEntity interview = interview(1L); + interview.setCompanySnapshot(new CompanySnapshot("3", "샘플커머스", "CONSUMER", + List.of(new CompanySnapshot.Fact("23", "TALENT", "오너십", "문제를 스스로 정의하고 결과까지 책임진다", + List.of("끝까지 해결한 경험을 설명함"), List.of())), + "샘플커머스는 가상의 기업이다.")); + when(interviewRepository.findById(3L)).thenReturn(Optional.of(interview)); + + service.deliver(tailor(true)); + + verify(chatServerClient).prepareInterview(sentRequest.capture()); + assertThat(sentRequest.getValue().getCompany()) + .isEqualTo(new ChatCompany("샘플커머스", "샘플커머스는 가상의 기업이다.")); + JsonNode company = JsonMapper.shared().valueToTree(sentRequest.getValue()).get("company"); + assertThat(company.propertyNames()).containsExactlyInAnyOrder("displayName", "interviewerBrief"); + } + + @Test + void 기업이_없으면_준비_요청에_company_키가_없다() { + service.deliver(tailor(true)); + + verify(chatServerClient).prepareInterview(sentRequest.capture()); + assertThat(JsonMapper.shared().valueToTree(sentRequest.getValue()).has("company")).isFalse(); + } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceChatRecordTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceChatRecordTest.java index e6d55308..6db4509b 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceChatRecordTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceChatRecordTest.java @@ -69,7 +69,7 @@ class InterviewServiceChatRecordTest { void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, null); + interviewPersonaRepository, null, null); when(interviewRepository.findById(3L)).thenReturn(Optional.of(InterviewEntity.builder() diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java index b2652dcf..55135ef5 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java @@ -3,11 +3,15 @@ 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.HttpStatus; +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.userdata.answer.repository.AnswerRepository; import repit.repit_api_server.domain.userdata.interview.dto.request.CreateInterviewRequest; import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse; @@ -18,6 +22,7 @@ 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.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.domain.userdata.question.repository.QuestionRepository; @@ -25,6 +30,7 @@ import repit.repit_api_server.global.client.ChatServerClient; import repit.repit_api_server.global.exception.BusinessException; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -54,6 +60,8 @@ class InterviewServiceCreateTest { private QuestionTailorService questionTailorService; @Mock private InterviewPersonaRepository interviewPersonaRepository; + @Mock + private CompanySnapshotFactory companySnapshotFactory; /** 인증을 마친 요청의 주인. 확인은 시큐리티 필터가 끝냈고, 서비스는 id만 받는다. */ private static final Long USER_ID = 7L; @@ -64,15 +72,22 @@ class InterviewServiceCreateTest { void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, null); + interviewPersonaRepository, null, companySnapshotFactory); when(personaRepository.findById(1L)).thenReturn(Optional.of(persona())); when(personaRepository.findByPersonaName("압박 면접관")).thenReturn(Optional.of(persona())); when(interviewRepository.save(any(InterviewEntity.class))) .thenAnswer(invocation -> invocation.getArgument(0)); + when(companySnapshotFactory.create(3L)).thenReturn(SNAPSHOT); + when(companySnapshotFactory.create(4L)).thenThrow(BusinessException.notFound("선택한 기업을 찾을 수 없습니다.")); } + private static final CompanySnapshot SNAPSHOT = new CompanySnapshot("3", "샘플커머스", "CONSUMER", + List.of(new CompanySnapshot.Fact("23", "TALENT", "오너십", "문제를 스스로 정의하고 결과까지 책임진다", + List.of("끝까지 해결한 경험을 설명함"), List.of())), + "샘플커머스는 가상의 기업이다."); + private PersonaEntity persona() { return PersonaEntity.builder() .personaId(1L) @@ -88,7 +103,7 @@ private PersonaEntity persona() { @Test void personaId가_있으면_이름은_보지_않는다() { InterviewResponse response = service.createInterview(USER_ID, - new CreateInterviewRequest(1L, "무시되는 이름", null)); + new CreateInterviewRequest(1L, "무시되는 이름", null, null)); assertThat(response.getPersonaId()).isEqualTo(1L); assertThat(response.getUserId()).isEqualTo(7L); @@ -98,7 +113,7 @@ private PersonaEntity persona() { @Test void personaId가_없으면_이름으로_찾는다() { InterviewResponse response = service.createInterview(USER_ID, - new CreateInterviewRequest(null, "압박 면접관", null)); + new CreateInterviewRequest(null, "압박 면접관", null, null)); assertThat(response.getPersonaId()).isEqualTo(1L); verify(personaRepository).findByPersonaName("압박 면접관"); @@ -107,7 +122,7 @@ private PersonaEntity persona() { @Test void 페르소나를_아예_지정하지_않으면_422다() { assertThatThrownBy(() -> service.createInterview(USER_ID, - new CreateInterviewRequest(null, " ", null))) + new CreateInterviewRequest(null, " ", null, null))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); @@ -118,9 +133,66 @@ private PersonaEntity persona() { when(personaRepository.findByPersonaName("없는 면접관")).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.createInterview(USER_ID, - new CreateInterviewRequest(null, "없는 면접관", null))) + new CreateInterviewRequest(null, "없는 면접관", null, null))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getStatus()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void 기업을_고르면_일대일_면접에_스냅샷을_남긴다() { + InterviewResponse response = service.createInterview(USER_ID, + new CreateInterviewRequest(1L, null, null, 3L)); + + ArgumentCaptor saved = ArgumentCaptor.forClass(InterviewEntity.class); + verify(interviewRepository).save(saved.capture()); + assertThat(saved.getValue().getCompanyId()).isEqualTo(3L); + assertThat(saved.getValue().getCompanySnapshot()).isEqualTo(SNAPSHOT); + assertThat(response.getCompany()).isEqualTo(new CompanyBadge(3L, "샘플커머스")); + } + + @Test + void 기업을_고르면_N대1_면접에도_스냅샷을_남긴다() { + when(personaRepository.findAllById(List.of(1L, 2L))).thenReturn(List.of( + PersonaEntity.builder().personaId(1L).role(Role.TECH).build(), + PersonaEntity.builder().personaId(2L).role(Role.HR).build())); + when(interviewRepository.save(any(InterviewEntity.class))).thenAnswer(invocation -> { + InterviewEntity interview = invocation.getArgument(0); + interview.setInterviewId(30L); + return interview; + }); + + InterviewResponse response = service.createInterview(USER_ID, + new CreateInterviewRequest(null, null, List.of(1L, 2L), 3L)); + + ArgumentCaptor saved = ArgumentCaptor.forClass(InterviewEntity.class); + verify(interviewRepository).save(saved.capture()); + assertThat(saved.getValue().getCompanyId()).isEqualTo(3L); + assertThat(saved.getValue().getCompanySnapshot()).isEqualTo(SNAPSHOT); + assertThat(response.getCompany()).isEqualTo(new CompanyBadge(3L, "샘플커머스")); + } + + @Test + void 기업을_고르지_않으면_기업도_스냅샷도_비어_있다() { + InterviewResponse response = service.createInterview(USER_ID, + new CreateInterviewRequest(1L, null, null, null)); + + ArgumentCaptor saved = ArgumentCaptor.forClass(InterviewEntity.class); + verify(interviewRepository).save(saved.capture()); + assertThat(saved.getValue().getCompanyId()).isNull(); + assertThat(saved.getValue().getCompanySnapshot()).isNull(); + assertThat(response.getCompany()).isNull(); + verify(companySnapshotFactory, never()).create(any()); + } + + /** 숨긴 기업과 없는 기업은 팩토리가 같은 404로 막는다. 면접은 저장하지 않는다. */ + @Test + void 공개되지_않은_기업을_고르면_404이고_면접을_만들지_않는다() { + assertThatThrownBy(() -> service.createInterview(USER_ID, new CreateInterviewRequest(1L, null, null, 4L))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.NOT_FOUND); + + verify(interviewRepository, never()).save(any()); } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceLookupTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceLookupTest.java index 7e3755e6..2abe3ccd 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceLookupTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceLookupTest.java @@ -65,7 +65,7 @@ class InterviewServiceLookupTest { void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, null); + interviewPersonaRepository, null, null); } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceMultiCreateTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceMultiCreateTest.java index c4c94a91..070d1bba 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceMultiCreateTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceMultiCreateTest.java @@ -73,7 +73,7 @@ class InterviewServiceMultiCreateTest { void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, null); + interviewPersonaRepository, null, null); when(interviewRepository.save(any(InterviewEntity.class))).thenAnswer(invocation -> { @@ -103,7 +103,7 @@ private PersonaEntity persona(long id, Role role) { persona(13L, Role.CEO), persona(11L, Role.TECH), persona(12L, Role.HR))); InterviewResponse response = service.createInterview(USER_ID, - new CreateInterviewRequest(null, null, List.of(13L, 11L, 12L))); + new CreateInterviewRequest(null, null, List.of(13L, 11L, 12L), null)); assertThat(response.getMode()).isEqualTo(InterviewMode.MULTI); // 면접관이 여럿이라 단일 personaId는 비워 둔다. @@ -126,7 +126,7 @@ private PersonaEntity persona(long id, Role role) { persona(11L, Role.TECH), persona(12L, Role.HR))); InterviewResponse response = service.createInterview(USER_ID, - new CreateInterviewRequest(null, null, List.of(11L, 12L))); + new CreateInterviewRequest(null, null, List.of(11L, 12L), null)); assertThat(response.getMode()).isEqualTo(InterviewMode.MULTI); assertThat(response.getPersonaIds()).containsExactly(11L, 12L); @@ -140,7 +140,7 @@ private PersonaEntity persona(long id, Role role) { persona(11L, Role.TECH))); InterviewResponse response = service.createInterview(USER_ID, - new CreateInterviewRequest(null, null, List.of(12L, 13L, 15L, 11L))); + new CreateInterviewRequest(null, null, List.of(12L, 13L, 15L, 11L), null)); assertThat(response.getMode()).isEqualTo(InterviewMode.MULTI); // 기술 면접관만 맨 앞으로 올라오고 나머지는 고른 순서 그대로다. @@ -169,7 +169,7 @@ private PersonaEntity persona(long id, Role role) { persona(15L, Role.PM), persona(16L, Role.DESIGN))); assertThatThrownBy(() -> service.createInterview(USER_ID, - new CreateInterviewRequest(null, null, List.of(11L, 12L, 13L, 15L, 16L)))) + new CreateInterviewRequest(null, null, List.of(11L, 12L, 13L, 15L, 16L), null))) .isInstanceOf(BusinessException.class) .hasMessageContaining("1명 이상 3명 이하") .extracting(e -> ((BusinessException) e).getStatus()) @@ -185,7 +185,7 @@ private PersonaEntity persona(long id, Role role) { persona(12L, Role.HR), persona(13L, Role.CEO))); assertThatThrownBy(() -> service.createInterview(USER_ID, - new CreateInterviewRequest(null, null, List.of(12L, 13L)))) + new CreateInterviewRequest(null, null, List.of(12L, 13L), null))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); @@ -198,7 +198,7 @@ private PersonaEntity persona(long id, Role role) { when(personaRepository.findAllById(List.of(11L))).thenReturn(List.of(persona(11L, Role.TECH))); assertThatThrownBy(() -> service.createInterview(USER_ID, - new CreateInterviewRequest(null, null, List.of(11L)))) + new CreateInterviewRequest(null, null, List.of(11L), null))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); @@ -210,7 +210,7 @@ private PersonaEntity persona(long id, Role role) { persona(11L, Role.TECH), persona(14L, Role.TECH), persona(12L, Role.HR))); assertThatThrownBy(() -> service.createInterview(USER_ID, - new CreateInterviewRequest(null, null, List.of(11L, 14L, 12L)))) + new CreateInterviewRequest(null, null, List.of(11L, 14L, 12L), null))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); @@ -219,7 +219,7 @@ private PersonaEntity persona(long id, Role role) { @Test void 같은_면접관을_두_번_지정하면_422다() { assertThatThrownBy(() -> service.createInterview(USER_ID, - new CreateInterviewRequest(null, null, List.of(11L, 11L, 12L)))) + new CreateInterviewRequest(null, null, List.of(11L, 11L, 12L), null))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); @@ -233,7 +233,7 @@ private PersonaEntity persona(long id, Role role) { persona(11L, Role.TECH), persona(12L, Role.HR))); assertThatThrownBy(() -> service.createInterview(USER_ID, - new CreateInterviewRequest(null, null, List.of(11L, 12L, 99L)))) + new CreateInterviewRequest(null, null, List.of(11L, 12L, 99L), null))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.NOT_FOUND); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java index 55b8d8c3..f8bf6094 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceSaveResultTest.java @@ -71,7 +71,7 @@ class InterviewServiceSaveResultTest { void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, answerRepository, personaRepository, questionTailorService, - interviewPersonaRepository, null); + interviewPersonaRepository, null, null); when(interviewRepository.findById(3L)).thenReturn(Optional.of(InterviewEntity.builder() .interviewId(3L) diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceRequestTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceRequestTest.java index 25df88b4..fa05191f 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceRequestTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceRequestTest.java @@ -9,6 +9,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.springframework.http.HttpStatus; +import repit.repit_api_server.domain.company.dto.CompanySnapshot; import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; import repit.repit_api_server.domain.userdata.interview.entity.InterviewPersonaEntity; import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; @@ -467,4 +468,50 @@ private int totalQuestionCount(QuestionTailorMultiRequest request) { verify(aiServerClient, never()).tailorQuestionsMulti(any()); } + + private static final CompanySnapshot SNAPSHOT = new CompanySnapshot("3", "샘플커머스", "CONSUMER", + List.of(new CompanySnapshot.Fact("23", "TALENT", "오너십", "문제를 스스로 정의하고 결과까지 책임진다", + List.of("끝까지 해결한 경험을 설명함"), List.of())), + "샘플커머스는 가상의 기업이다."); + + private InterviewEntity interview(InterviewMode mode, CompanySnapshot snapshot) { + InterviewEntity interview = interview(mode); + interview.setCompanySnapshot(snapshot); + return interview; + } + + /** 면접을 만들 때 남긴 스냅샷을 그대로 싣는다. 기업 테이블을 다시 읽지 않는다. */ + @Test + void 기업을_고른_면접은_재작성_요청에_스냅샷을_싣는다() { + givenMultiPersonas(); + + service.requestTailor(interview(InterviewMode.SOLO, SNAPSHOT), user); + service.requestTailor(interview(InterviewMode.MULTI, SNAPSHOT), user); + + ArgumentCaptor solo = ArgumentCaptor.forClass(QuestionTailorRequest.class); + verify(aiServerClient).tailorQuestions(solo.capture()); + assertThat(solo.getValue().getCompany()).isEqualTo(SNAPSHOT); + + ArgumentCaptor multi = ArgumentCaptor.forClass(QuestionTailorMultiRequest.class); + verify(aiServerClient).tailorQuestionsMulti(multi.capture()); + assertThat(multi.getValue().getCompany()).isEqualTo(SNAPSHOT); + } + + /** 기업이 없는 면접은 지금과 같은 본문이어야 한다. null도 보내지 않는다. */ + @Test + void 기업이_없으면_재작성_요청에_company_키가_없다() { + givenMultiPersonas(); + ObjectMapper mapper = new ObjectMapper(); + + service.requestTailor(interview(InterviewMode.SOLO), user); + service.requestTailor(interview(InterviewMode.MULTI), user); + + ArgumentCaptor solo = ArgumentCaptor.forClass(QuestionTailorRequest.class); + verify(aiServerClient).tailorQuestions(solo.capture()); + assertThat(mapper.readTree(mapper.writeValueAsString(solo.getValue())).has("company")).isFalse(); + + ArgumentCaptor multi = ArgumentCaptor.forClass(QuestionTailorMultiRequest.class); + verify(aiServerClient).tailorQuestionsMulti(multi.capture()); + assertThat(mapper.readTree(mapper.writeValueAsString(multi.getValue())).has("company")).isFalse(); + } }