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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
/**
* 면접에서 고를 수 있는 기업. 기동할 때 기업 데이터 패키지에서 가져온다 — {@code CompanyDataImporter} 참고.
*
* <p>행은 지우지 않는다. 면접이 company_id로 가리키고 있어, 패키지에서 빠진 기업은 HIDDEN으로만 바꾼다.
* <p>행은 지우지 않는다. 면접이 company_id로 가리키고 있어, 내릴 기업은 설정(app.company.hidden-codes)으로 HIDDEN만 만든다.
* 목록·신규 선택에 내보내는 조건은 {@code CompanyVisibility}가 정한다.
*/
@Entity
@Table(name = "company")
Expand Down Expand Up @@ -80,6 +81,10 @@ public class CompanyEntity {
@Column(nullable = false)
private LocalDate dataAsOf;

// 출처가 모두 SAMPLE인 개발용 패키지. 허용한 환경(app.company.allow-sample)에서만 목록·면접 생성에 내보낸다.
@Column(nullable = false)
private boolean sample;

@Column(length = 50)
private String reviewedBy;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package repit.repit_api_server.domain.company.entity.enums;

/** 기업 행은 지우지 않는다. 패키지에서 빠지면 HIDDEN이 되고 목록·면접 생성에서 보이지 않는다. */
/**
* 기업 행은 지우지 않는다. HIDDEN은 목록·면접 생성에서 보이지 않고, 이미 만든 면접의 스냅샷은 그대로 둔다.
*
* <p>HIDDEN은 설정(app.company.hidden-codes)으로만 만든다. 패키지 파일이 디렉터리에서 빠졌다고 바꾸지 않는다.
*/
public enum CompanyStatus {
PUBLISHED,
HIDDEN
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@
import static repit.repit_api_server.domain.company.CompanyLimits.length;

/**
* 패키지 파일 하나에 적용하는 검증(01 §3 규칙 1~7, 9). 하나라도 어기면 그 파일만 건너뛴다.
* 패키지 파일 하나에 적용하는 검증(02 §3-2 규칙 1~5, 7). 하나라도 어기면 그 파일만 건너뛴다.
*
* <p>묶음 전체를 봐야 하는 규칙(8 유일성, 10 모회사)은 모든 파일을 읽은 뒤 가져오기가 본다.
* <p>묶음 전체를 봐야 하는 규칙(6 유일성, 8 모회사)은 모든 파일을 읽은 뒤 가져오기가 본다. 모회사를 자기 자신으로 둔
* 파일도 여기서 막지 않는다 — 관계만 저장하지 않으면 되는 일이라 가져오기가 연결을 빼고 경고한다.
* 여기를 통과한 데이터는 분석 서버에서 422가 나지 않는다 — 상한이 분석 서버 계약과 같다.
*
* <p>오류 문구는 로그에 남는다. 파일의 값(주소·법인 식별번호·문장)은 싣지 않고 어느 필드가 어떤 규칙을 어겼는지만 쓴다.
*/
public final class CompanyDataValidator {

Expand All @@ -43,7 +46,7 @@ public static List<String> validate(String fileName, CompanyDataPackage pkg) {

// 1. 필수 필드와 길이·개수 상한
if (pkg.code() == null || !CODE.matcher(pkg.code()).matches()) {
errors.add("code는 소문자·숫자·하이픈 2~40자여야 합니다: " + pkg.code());
errors.add("code는 소문자·숫자·하이픈 2~40자여야 합니다.");
}
text(errors, "displayName", pkg.displayName(), CompanyLimits.DISPLAY_NAME, true);
text(errors, "legalName", pkg.legalName(), 100, true);
Expand All @@ -55,10 +58,6 @@ public static List<String> validate(String fileName, CompanyDataPackage pkg) {
pattern(errors, "dartCorpCode", pkg.dartCorpCode(), DART_CORP_CODE, "8자리 숫자");
pattern(errors, "businessRegNo", pkg.businessRegNo(), BUSINESS_REG_NO, "10자리 숫자");
required(errors, "dataAsOf", pkg.dataAsOf());
// 자기 자신을 모회사로 두면 목록에 자기 이름이 모회사로 나가고, 모회사를 따라가는 코드가 생기면 끝없이 돈다.
if (pkg.parentCode() != null && pkg.parentCode().equals(pkg.code())) {
errors.add("parentCode가 자기 code와 같습니다: " + pkg.code());
}
if (pkg.review() != null) {
// 컬럼이 VARCHAR(50)이다. 넘으면 검증 오류 대신 저장 단계의 예외로만 드러난다.
text(errors, "review.reviewedBy", pkg.review().reviewedBy(), 50, false);
Expand All @@ -72,7 +71,7 @@ public static List<String> validate(String fileName, CompanyDataPackage pkg) {
text(errors, at + "sourceKey", source.sourceKey(), 40, true);
required(errors, at + "sourceType", source.sourceType());
if (source.url() == null || !source.url().startsWith("https://")) {
errors.add(at + "url은 https:// 주소여야 합니다: " + source.url());
errors.add(at + "url은 https:// 주소여야 합니다.");
}
text(errors, at + "title", source.title(), 200, false);
text(errors, at + "scope", source.scope(), 100, false);
Expand Down Expand Up @@ -108,15 +107,16 @@ public static List<String> validate(String fileName, CompanyDataPackage pkg) {

// 4. 출처 연결
Set<String> sourceKeys = new HashSet<>();
for (CompanyDataPackage.Source source : sources) {
if (source.sourceKey() != null && !sourceKeys.add(source.sourceKey())) {
errors.add("sourceKey가 겹칩니다: " + source.sourceKey());
for (int i = 0; i < sources.size(); i++) {
String sourceKey = sources.get(i).sourceKey();
if (sourceKey != null && !sourceKeys.add(sourceKey)) {
errors.add("sourceKey가 겹칩니다: sources[" + i + "]");
}
}
for (int i = 0; i < facts.size(); i++) {
String sourceKey = facts.get(i).sourceKey();
if (sourceKey != null && !sourceKeys.contains(sourceKey)) {
errors.add("facts[" + i + "].sourceKey가 sources에 없습니다: " + sourceKey);
errors.add("facts[" + i + "].sourceKey가 sources에 없습니다.");
}
}

Expand Down Expand Up @@ -176,15 +176,16 @@ private static void signals(List<String> errors, String field, List<String> sign
}
}

// 실행 입력은 앞뒤 공백을 뺀 뒤 센다(00 §2). 스냅샷도 같은 값을 싣는다.
private static void text(List<String> 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) + "자");
if (length(value.strip()) > max) {
errors.add(field + "는 " + max + "자 이하여야 합니다: " + length(value.strip()) + "자");
}
}

Expand All @@ -202,7 +203,7 @@ private static void required(List<String> errors, String field, Object value) {

private static void pattern(List<String> errors, String field, String value, Pattern pattern, String rule) {
if (value != null && !pattern.matcher(value).matches()) {
errors.add(field + "는 " + rule + "여야 합니다: " + value);
errors.add(field + "는 " + rule + "여야 합니다.");
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package repit.repit_api_server.domain.company.repository;

import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
Expand All @@ -16,13 +18,26 @@ public interface CompanyRepository extends JpaRepository<CompanyEntity, Long> {

Optional<CompanyEntity> findByCode(String code);

List<CompanyEntity> findAllByCodeIn(Collection<String> codes);

List<CompanyEntity> findAllByStatus(CompanyStatus status);

// 목록에 모회사 이름을 함께 싣는다. 모회사를 기업마다 따로 읽지 않도록 한 번에 가져온다.
@EntityGraph(attributePaths = "parent")
List<CompanyEntity> findWithParentByStatus(CompanyStatus status);

Optional<CompanyEntity> findByCompanyIdAndStatus(Long companyId, CompanyStatus status);
/**
* 스냅샷을 만들 때 기업 행을 공유 잠금(FOR SHARE)으로 읽는다. 가져오기는 같은 행을 FOR UPDATE로 잡고 기업·fact를
* 바꾸므로, 둘이 겹치면 한쪽이 커밋할 때까지 기다린다 — 이전 요약과 새 fact가 섞인 스냅샷이 생기지 않는다.
*/
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("select c from CompanyEntity c where c.companyId = :companyId")
Optional<CompanyEntity> findForShareByCompanyId(@Param("companyId") Long companyId);

// 가져오기가 기존 기업을 고치기 전에 잡는다. 위 공유 잠금과 짝이다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from CompanyEntity c where c.code = :code")
Optional<CompanyEntity> findForUpdateByCode(@Param("code") String code);

// 숨긴 기업이 들고 있는 식별자를 놓게 한다. 같은 법인을 다른 code로 다시 낸 패키지가 UNIQUE에 걸리지 않게 한다.
@Modifying(flushAutomatically = true, clearAutomatically = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@
/**
* 공개 기업 목록·검색.
*
* <p>공개 기업이 30개 안팎이라 요청마다 전부 읽어 메모리에서 거른다. 캐시는 두지 않는다.
* <p>공개 기업이 30개 안팎이라 요청마다 전부 읽어 메모리에서 거른다. 캐시는 두지 않는다. 검색어를 SQL 패턴으로 넘기지 않으므로
* {@code %}·{@code _}도 글자 그대로 찾는다.
*
* <p>내보내는 기업은 {@link CompanyVisibility} 조건을 지킨다. 공개 기업이 없으면 빈 목록이고, 조회 장애는 예외(5xx)로 나가
* 빈 목록과 구분된다.
*/
@Service
@RequiredArgsConstructor
Expand All @@ -37,6 +41,7 @@ public class CompanyQueryService {

private final CompanyRepository companyRepository;
private final CompanyFactRepository companyFactRepository;
private final CompanyVisibility companyVisibility;

@Transactional(readOnly = true)
public List<CompanySummaryResponse> search(String q, CompanyCategory category) {
Expand All @@ -46,11 +51,14 @@ public List<CompanySummaryResponse> search(String q, CompanyCategory category) {
"검색어는 " + QUERY_MIN_LENGTH + "~" + QUERY_MAX_LENGTH + "자로 입력해주세요.");
}

// 정렬은 분류 선언 순서, 이름, companyId 순으로 고정한다. 이름이 같아도 요청마다 순서가 바뀌지 않는다.
List<CompanyEntity> companies = companyRepository.findWithParentByStatus(CompanyStatus.PUBLISHED).stream()
.filter(companyVisibility::isSelectable)
.filter(company -> category == null || company.getCategory() == category)
.filter(company -> query.isEmpty() || matches(company, query))
.sorted(Comparator.comparing(CompanyEntity::getCategory)
.thenComparing(CompanyEntity::getDisplayName))
.thenComparing(CompanyEntity::getDisplayName)
.thenComparing(CompanyEntity::getCompanyId))
.toList();
if (companies.isEmpty()) {
return List.of();
Expand All @@ -76,10 +84,10 @@ public List<CompanySummaryResponse> search(String q, CompanyCategory category) {
.toList();
}

// 숨긴 모회사의 이름은 내보내지 않는다. 정보 수정·삭제 요청으로 내린 기업 이름이 계열사 카드에 계속 남는다.
private static String parentDisplayName(CompanyEntity company) {
// 내보낼 수 없는 모회사의 이름은 싣지 않는다. 정보 수정·삭제 요청으로 내린 기업 이름이 계열사 카드에 계속 남는다.
private String parentDisplayName(CompanyEntity company) {
CompanyEntity parent = company.getParent();
return parent == null || parent.getStatus() != CompanyStatus.PUBLISHED ? null : parent.getDisplayName();
return companyVisibility.isSelectable(parent) ? parent.getDisplayName() : null;
}

/** 보이는 이름, 법인 이름, 별칭 가운데 하나라도 검색어를 품으면 맞다. 공백·대소문자는 보지 않는다. */
Expand Down
Loading
Loading