From 2caab5480f70181956904ce607f4a3379f98e1a7 Mon Sep 17 00:00:00 2001 From: hywznn Date: Fri, 7 Aug 2026 18:07:25 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(import):=20=EA=B7=BC=EB=A1=9C=EC=9E=90?= =?UTF-8?q?=20=EB=AA=85=EB=8B=A8=20=EC=8A=A4=ED=82=A4=EB=A7=88=EC=99=80=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=ED=95=9C=20CSV=C2=B7XLSX=20=ED=8C=8C?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../application/ImportValidationError.java | 4 + .../application/ParsedWorkerImport.java | 11 + .../application/WorkerImportJobRecord.java | 32 ++ .../application/WorkerImportRowPatch.java | 9 + .../application/WorkerImportRowRecord.java | 24 ++ .../application/WorkerImportView.java | 11 + .../error/WorkerImportErrorCode.java | 43 +++ .../port/WorkerImportFileParser.java | 7 + .../port/WorkerImportRepository.java | 54 +++ .../domain/WorkerImportField.java | 32 ++ .../domain/WorkerImportRowStatus.java | 9 + .../domain/WorkerImportStatus.java | 9 + .../DefaultWorkerImportFileParser.java | 236 +++++++++++++ .../JdbcWorkerImportRepository.java | 321 ++++++++++++++++++ .../server/workerimport/package-info.java | 4 + .../V33__prepare_worker_import_rls.sql | 21 ++ .../migration/V32__create_worker_import.sql | 97 ++++++ 18 files changed, 925 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerimport/application/ImportValidationError.java create mode 100644 src/main/java/com/fowoco/server/workerimport/application/ParsedWorkerImport.java create mode 100644 src/main/java/com/fowoco/server/workerimport/application/WorkerImportJobRecord.java create mode 100644 src/main/java/com/fowoco/server/workerimport/application/WorkerImportRowPatch.java create mode 100644 src/main/java/com/fowoco/server/workerimport/application/WorkerImportRowRecord.java create mode 100644 src/main/java/com/fowoco/server/workerimport/application/WorkerImportView.java create mode 100644 src/main/java/com/fowoco/server/workerimport/application/error/WorkerImportErrorCode.java create mode 100644 src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportFileParser.java create mode 100644 src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportRepository.java create mode 100644 src/main/java/com/fowoco/server/workerimport/domain/WorkerImportField.java create mode 100644 src/main/java/com/fowoco/server/workerimport/domain/WorkerImportRowStatus.java create mode 100644 src/main/java/com/fowoco/server/workerimport/domain/WorkerImportStatus.java create mode 100644 src/main/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParser.java create mode 100644 src/main/java/com/fowoco/server/workerimport/infrastructure/persistence/JdbcWorkerImportRepository.java create mode 100644 src/main/java/com/fowoco/server/workerimport/package-info.java create mode 100644 src/main/resources/db/migration-postgresql/V33__prepare_worker_import_rls.sql create mode 100644 src/main/resources/db/migration/V32__create_worker_import.sql diff --git a/build.gradle b/build.gradle index a6beb7d6..db14f25b 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' compileOnly 'org.projectlombok:lombok' implementation 'org.apache.poi:poi:5.4.0' + implementation 'org.apache.poi:poi-ooxml:5.4.0' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.flywaydb:flyway-database-postgresql' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/fowoco/server/workerimport/application/ImportValidationError.java b/src/main/java/com/fowoco/server/workerimport/application/ImportValidationError.java new file mode 100644 index 00000000..c716384d --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/application/ImportValidationError.java @@ -0,0 +1,4 @@ +package com.fowoco.server.workerimport.application; + +public record ImportValidationError(String field, String code, String message) { +} diff --git a/src/main/java/com/fowoco/server/workerimport/application/ParsedWorkerImport.java b/src/main/java/com/fowoco/server/workerimport/application/ParsedWorkerImport.java new file mode 100644 index 00000000..c134b336 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/application/ParsedWorkerImport.java @@ -0,0 +1,11 @@ +package com.fowoco.server.workerimport.application; + +import java.util.List; +import java.util.Map; + +public record ParsedWorkerImport(List headers, List> rows) { + public ParsedWorkerImport { + headers = List.copyOf(headers); + rows = rows.stream().map(Map::copyOf).toList(); + } +} diff --git a/src/main/java/com/fowoco/server/workerimport/application/WorkerImportJobRecord.java b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportJobRecord.java new file mode 100644 index 00000000..b57b2a34 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportJobRecord.java @@ -0,0 +1,32 @@ +package com.fowoco.server.workerimport.application; + +import com.fowoco.server.workerimport.domain.WorkerImportField; +import com.fowoco.server.workerimport.domain.WorkerImportStatus; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record WorkerImportJobRecord( + UUID importId, + UUID companyId, + UUID sourceFileId, + UUID createdBy, + WorkerImportStatus status, + List sourceHeaders, + Map mappings, + String createIdempotencyKeyHash, + String createRequestHash, + String lastCommitIdempotencyKeyHash, + String lastCommitRequestHash, + int totalRows, + int validRows, + int invalidRows, + int excludedRows, + int committedRows, + Instant sourceFileExpiresAt, + Instant createdAt, + Instant updatedAt, + long version +) { +} diff --git a/src/main/java/com/fowoco/server/workerimport/application/WorkerImportRowPatch.java b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportRowPatch.java new file mode 100644 index 00000000..bfb2c933 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportRowPatch.java @@ -0,0 +1,9 @@ +package com.fowoco.server.workerimport.application; + +import java.util.Map; + +public record WorkerImportRowPatch(int rowNumber, Boolean excluded, Map values) { + public WorkerImportRowPatch { + values = values == null ? Map.of() : Map.copyOf(values); + } +} diff --git a/src/main/java/com/fowoco/server/workerimport/application/WorkerImportRowRecord.java b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportRowRecord.java new file mode 100644 index 00000000..dd6eea79 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportRowRecord.java @@ -0,0 +1,24 @@ +package com.fowoco.server.workerimport.application; + +import com.fowoco.server.workerimport.domain.WorkerImportRowStatus; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record WorkerImportRowRecord( + UUID importRowId, + UUID importId, + UUID companyId, + int rowNumber, + Map sourceValues, + Map overrideValues, + Map normalizedValues, + List validationErrors, + WorkerImportRowStatus status, + UUID workerId, + Instant createdAt, + Instant updatedAt, + long version +) { +} diff --git a/src/main/java/com/fowoco/server/workerimport/application/WorkerImportView.java b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportView.java new file mode 100644 index 00000000..e7129898 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportView.java @@ -0,0 +1,11 @@ +package com.fowoco.server.workerimport.application; + +import java.util.List; + +public record WorkerImportView( + WorkerImportJobRecord job, + List rows, + int page, + int size +) { +} diff --git a/src/main/java/com/fowoco/server/workerimport/application/error/WorkerImportErrorCode.java b/src/main/java/com/fowoco/server/workerimport/application/error/WorkerImportErrorCode.java new file mode 100644 index 00000000..ba603d59 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/application/error/WorkerImportErrorCode.java @@ -0,0 +1,43 @@ +package com.fowoco.server.workerimport.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum WorkerImportErrorCode implements ApiErrorCode { + IMPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "가져오기 작업을 찾을 수 없습니다."), + IMPORT_FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "가져오기 파일은 5MB를 넘을 수 없습니다."), + IMPORT_FILE_TYPE_UNSUPPORTED(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "CSV 또는 XLSX 파일만 사용할 수 있습니다."), + IMPORT_FILE_INVALID(HttpStatus.UNPROCESSABLE_CONTENT, "파일 구조 또는 문자 인코딩을 확인해 주세요."), + IMPORT_FILE_EMPTY(HttpStatus.UNPROCESSABLE_CONTENT, "헤더와 한 개 이상의 데이터 행이 필요합니다."), + IMPORT_FILE_LIMIT_EXCEEDED(HttpStatus.UNPROCESSABLE_CONTENT, "파일은 최대 1,000행, 50열까지 처리할 수 있습니다."), + IMPORT_FORMULA_NOT_ALLOWED(HttpStatus.UNPROCESSABLE_CONTENT, "수식이 포함된 셀은 가져올 수 없습니다."), + IMPORT_SENSITIVE_COLUMN_NOT_ALLOWED(HttpStatus.UNPROCESSABLE_CONTENT, "여권번호·외국인등록번호·계좌번호 열은 가져올 수 없습니다."), + IMPORT_MAPPING_INVALID(HttpStatus.UNPROCESSABLE_CONTENT, "열 연결 정보를 확인해 주세요."), + IMPORT_STATE_INVALID(HttpStatus.CONFLICT, "현재 단계에서는 요청한 작업을 수행할 수 없습니다."), + IMPORT_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."), + IMPORT_IDEMPOTENCY_CONFLICT(HttpStatus.CONFLICT, "같은 Idempotency-Key가 다른 요청에 사용되었습니다."), + IMPORT_NO_VALID_ROWS(HttpStatus.UNPROCESSABLE_CONTENT, "등록할 수 있는 정상 행이 없습니다."); + + private final HttpStatus status; + private final String message; + + WorkerImportErrorCode(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String defaultMessage() { + return message; + } +} diff --git a/src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportFileParser.java b/src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportFileParser.java new file mode 100644 index 00000000..3eaf4fa6 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportFileParser.java @@ -0,0 +1,7 @@ +package com.fowoco.server.workerimport.application.port; + +import com.fowoco.server.workerimport.application.ParsedWorkerImport; + +public interface WorkerImportFileParser { + ParsedWorkerImport parse(String fileName, byte[] content); +} diff --git a/src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportRepository.java b/src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportRepository.java new file mode 100644 index 00000000..6b3b0871 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportRepository.java @@ -0,0 +1,54 @@ +package com.fowoco.server.workerimport.application.port; + +import com.fowoco.server.workerimport.application.ImportValidationError; +import com.fowoco.server.workerimport.application.WorkerImportJobRecord; +import com.fowoco.server.workerimport.application.WorkerImportRowRecord; +import com.fowoco.server.workerimport.domain.WorkerImportField; +import com.fowoco.server.workerimport.domain.WorkerImportRowStatus; +import com.fowoco.server.workerimport.domain.WorkerImportStatus; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public interface WorkerImportRepository { + void insert(WorkerImportJobRecord job, List rows); + + Optional findJob(UUID companyId, UUID importId); + + Optional findByCreateKey(UUID companyId, String keyHash); + + List findRows(UUID companyId, UUID importId, int offset, int limit); + + List findAllRows(UUID companyId, UUID importId); + + boolean existsWorkerByDisplayName(UUID companyId, String displayName); + + boolean updateJob( + UUID companyId, + UUID importId, + long expectedVersion, + WorkerImportStatus status, + Map mappings, + int validRows, + int invalidRows, + int excludedRows, + int committedRows, + String commitKeyHash, + String commitRequestHash, + Instant updatedAt + ); + + void updateRow( + UUID companyId, + UUID importId, + int rowNumber, + Map overrideValues, + Map normalizedValues, + List errors, + WorkerImportRowStatus status, + UUID workerId, + Instant updatedAt + ); +} diff --git a/src/main/java/com/fowoco/server/workerimport/domain/WorkerImportField.java b/src/main/java/com/fowoco/server/workerimport/domain/WorkerImportField.java new file mode 100644 index 00000000..40b1ab35 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/domain/WorkerImportField.java @@ -0,0 +1,32 @@ +package com.fowoco.server.workerimport.domain; + +import java.util.Arrays; + +public enum WorkerImportField { + DISPLAY_NAME("display_name"), + NATIONALITY_CODE("nationality_code"), + PREFERRED_LANGUAGE("preferred_language"), + VISA_TYPE("visa_type"), + STAY_EXPIRY_DATE("stay_expiry_date"), + CONTRACT_START_DATE("contract_start_date"), + CONTRACT_END_DATE("contract_end_date"), + EMPLOYMENT_PERMIT_END_DATE("employment_permit_end_date"), + EMPLOYMENT_ACTIVITY_END_DATE("employment_activity_end_date"); + + private final String key; + + WorkerImportField(String key) { + this.key = key; + } + + public String key() { + return key; + } + + public static WorkerImportField fromKey(String key) { + return Arrays.stream(values()) + .filter(field -> field.key.equals(key)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported worker import field: " + key)); + } +} diff --git a/src/main/java/com/fowoco/server/workerimport/domain/WorkerImportRowStatus.java b/src/main/java/com/fowoco/server/workerimport/domain/WorkerImportRowStatus.java new file mode 100644 index 00000000..423cbf6e --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/domain/WorkerImportRowStatus.java @@ -0,0 +1,9 @@ +package com.fowoco.server.workerimport.domain; + +public enum WorkerImportRowStatus { + PENDING, + VALID, + INVALID, + EXCLUDED, + COMMITTED +} diff --git a/src/main/java/com/fowoco/server/workerimport/domain/WorkerImportStatus.java b/src/main/java/com/fowoco/server/workerimport/domain/WorkerImportStatus.java new file mode 100644 index 00000000..53e9e843 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/domain/WorkerImportStatus.java @@ -0,0 +1,9 @@ +package com.fowoco.server.workerimport.domain; + +public enum WorkerImportStatus { + UPLOADED, + MAPPED, + REVIEW_REQUIRED, + READY, + COMMITTED +} diff --git a/src/main/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParser.java b/src/main/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParser.java new file mode 100644 index 00000000..bdc03dc8 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParser.java @@ -0,0 +1,236 @@ +package com.fowoco.server.workerimport.infrastructure.parsing; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.workerimport.application.ParsedWorkerImport; +import com.fowoco.server.workerimport.application.error.WorkerImportErrorCode; +import com.fowoco.server.workerimport.application.port.WorkerImportFileParser; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.stereotype.Component; + +@Component +public class DefaultWorkerImportFileParser implements WorkerImportFileParser { + + static final int MAX_ROWS = 1_000; + static final int MAX_COLUMNS = 50; + static final int MAX_CELL_LENGTH = 500; + private static final Pattern FORMULA_PREFIX = Pattern.compile("^[=+\\-@].*"); + private static final Set BLOCKED_HEADERS = Set.of( + "passportnumber", "passportno", "여권번호", + "alienregistrationnumber", "alienregistrationno", "외국인등록번호", "주민등록번호", + "accountnumber", "bankaccount", "계좌번호", + "phone", "phonenumber", "mobile", "전화번호", "휴대폰", "휴대전화", + "email", "emailaddress", "이메일", "메일주소", + "address", "homeaddress", "주소", "거주지주소", + "dateofbirth", "birthdate", "생년월일", + "legalname", "법정실명" + ); + + @Override + public ParsedWorkerImport parse(String fileName, byte[] content) { + if (fileName == null || fileName.isBlank()) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_TYPE_UNSUPPORTED); + } + String lowerName = fileName.toLowerCase(Locale.ROOT); + ParsedWorkerImport parsed; + if (lowerName.endsWith(".csv")) { + parsed = parseCsv(content); + } else if (lowerName.endsWith(".xlsx")) { + parsed = parseXlsx(content); + } else { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_TYPE_UNSUPPORTED); + } + validateParsed(parsed); + return parsed; + } + + private ParsedWorkerImport parseCsv(byte[] content) { + String text; + try { + text = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(content)) + .toString(); + } catch (CharacterCodingException exception) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_INVALID); + } + if (text.startsWith("\uFEFF")) { + text = text.substring(1); + } + List> table = parseCsvTable(text); + return toParsed(table); + } + + private List> parseCsvTable(String text) { + List> rows = new ArrayList<>(); + List row = new ArrayList<>(); + StringBuilder cell = new StringBuilder(); + boolean quoted = false; + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + if (quoted) { + if (current == '"') { + if (index + 1 < text.length() && text.charAt(index + 1) == '"') { + cell.append('"'); + index++; + } else { + quoted = false; + } + } else { + cell.append(current); + } + continue; + } + if (current == '"' && cell.length() == 0) { + quoted = true; + } else if (current == ',') { + row.add(cell.toString()); + cell.setLength(0); + } else if (current == '\n' || current == '\r') { + if (current == '\r' && index + 1 < text.length() && text.charAt(index + 1) == '\n') { + index++; + } + row.add(cell.toString()); + cell.setLength(0); + rows.add(row); + row = new ArrayList<>(); + } else { + cell.append(current); + } + } + if (quoted) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_INVALID); + } + if (cell.length() > 0 || !row.isEmpty()) { + row.add(cell.toString()); + rows.add(row); + } + return rows; + } + + private ParsedWorkerImport parseXlsx(byte[] content) { + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(content))) { + if (workbook.getNumberOfSheets() == 0) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_EMPTY); + } + Sheet sheet = workbook.getSheetAt(0); + if (sheet.getLastRowNum() > MAX_ROWS) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_LIMIT_EXCEEDED); + } + DataFormatter formatter = new DataFormatter(Locale.ROOT); + List> table = new ArrayList<>(); + for (int rowIndex = 0; rowIndex <= sheet.getLastRowNum(); rowIndex++) { + Row sourceRow = sheet.getRow(rowIndex); + if (sourceRow == null) { + table.add(List.of()); + continue; + } + int lastCell = Math.max(sourceRow.getLastCellNum(), 0); + if (lastCell > MAX_COLUMNS) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_LIMIT_EXCEEDED); + } + List values = new ArrayList<>(lastCell); + for (int column = 0; column < lastCell; column++) { + Cell sourceCell = sourceRow.getCell(column, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL); + if (sourceCell == null) { + values.add(""); + } else if (sourceCell.getCellType() == CellType.FORMULA) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FORMULA_NOT_ALLOWED); + } else { + values.add(formatter.formatCellValue(sourceCell)); + } + } + table.add(values); + } + return toParsed(table); + } catch (ApiException exception) { + throw exception; + } catch (IOException | RuntimeException exception) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_INVALID); + } + } + + private ParsedWorkerImport toParsed(List> table) { + while (!table.isEmpty() && isBlankRow(table.get(table.size() - 1))) { + table.remove(table.size() - 1); + } + if (table.size() < 2) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_EMPTY); + } + List headers = table.get(0).stream().map(String::strip).toList(); + if (headers.size() > MAX_COLUMNS || table.size() - 1 > MAX_ROWS) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_LIMIT_EXCEEDED); + } + List> rows = new ArrayList<>(); + for (int rowIndex = 1; rowIndex < table.size(); rowIndex++) { + List cells = table.get(rowIndex); + if (cells.size() > MAX_COLUMNS) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_LIMIT_EXCEEDED); + } + if (isBlankRow(cells)) { + continue; + } + Map values = new LinkedHashMap<>(); + for (int column = 0; column < headers.size(); column++) { + String value = column < cells.size() ? cells.get(column).strip() : ""; + validateCell(value); + values.put(headers.get(column), value); + } + rows.add(values); + } + return new ParsedWorkerImport(headers, rows); + } + + private void validateParsed(ParsedWorkerImport parsed) { + if (parsed.headers().isEmpty() || parsed.rows().isEmpty()) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_EMPTY); + } + Set seen = new HashSet<>(); + for (String header : parsed.headers()) { + if (header.isBlank() || !seen.add(header.toLowerCase(Locale.ROOT))) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_INVALID); + } + String normalized = normalizeHeader(header); + if (BLOCKED_HEADERS.contains(normalized)) { + throw new ApiException(WorkerImportErrorCode.IMPORT_SENSITIVE_COLUMN_NOT_ALLOWED); + } + } + } + + private void validateCell(String value) { + if (value.length() > MAX_CELL_LENGTH) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_INVALID); + } + if (FORMULA_PREFIX.matcher(value).matches()) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FORMULA_NOT_ALLOWED); + } + } + + private String normalizeHeader(String header) { + return header.toLowerCase(Locale.ROOT).replaceAll("[\\s._-]", ""); + } + + private boolean isBlankRow(List cells) { + return cells.stream().allMatch(value -> value == null || value.isBlank()); + } +} diff --git a/src/main/java/com/fowoco/server/workerimport/infrastructure/persistence/JdbcWorkerImportRepository.java b/src/main/java/com/fowoco/server/workerimport/infrastructure/persistence/JdbcWorkerImportRepository.java new file mode 100644 index 00000000..2a44123a --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/infrastructure/persistence/JdbcWorkerImportRepository.java @@ -0,0 +1,321 @@ +package com.fowoco.server.workerimport.infrastructure.persistence; + +import com.fowoco.server.workerimport.application.ImportValidationError; +import com.fowoco.server.workerimport.application.WorkerImportJobRecord; +import com.fowoco.server.workerimport.application.WorkerImportRowRecord; +import com.fowoco.server.workerimport.application.port.WorkerImportRepository; +import com.fowoco.server.workerimport.domain.WorkerImportField; +import com.fowoco.server.workerimport.domain.WorkerImportRowStatus; +import com.fowoco.server.workerimport.domain.WorkerImportStatus; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Repository +public class JdbcWorkerImportRepository implements WorkerImportRepository { + + private static final String JOB_COLUMNS = """ + import_id, company_id, source_file_id, created_by, status, + source_headers_json, mapping_json, create_idempotency_key_hash, + create_request_hash, last_commit_idempotency_key_hash, + last_commit_request_hash, total_rows, valid_rows, invalid_rows, + excluded_rows, committed_rows, source_file_expires_at, + created_at, updated_at, version + """; + private static final String ROW_COLUMNS = """ + import_row_id, import_id, company_id, row_number, + source_values_json, override_values_json, normalized_values_json, + validation_errors_json, status, worker_id, created_at, updated_at, version + """; + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + public JdbcWorkerImportRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) { + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + } + + @Override + public void insert(WorkerImportJobRecord job, List rows) { + jdbcTemplate.update( + """ + INSERT INTO worker_import_job ( + import_id, company_id, source_file_id, created_by, status, + source_headers_json, mapping_json, create_idempotency_key_hash, + create_request_hash, total_rows, valid_rows, invalid_rows, + excluded_rows, committed_rows, source_file_expires_at, + created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + job.importId(), job.companyId(), job.sourceFileId(), job.createdBy(), job.status().name(), + encode(job.sourceHeaders()), encodeMappings(job.mappings()), job.createIdempotencyKeyHash(), + job.createRequestHash(), job.totalRows(), job.validRows(), job.invalidRows(), + job.excludedRows(), job.committedRows(), timestamp(job.sourceFileExpiresAt()), + timestamp(job.createdAt()), timestamp(job.updatedAt()), job.version() + ); + for (WorkerImportRowRecord row : rows) { + jdbcTemplate.update( + """ + INSERT INTO worker_import_row ( + import_row_id, import_id, company_id, row_number, + source_values_json, override_values_json, normalized_values_json, + validation_errors_json, status, worker_id, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + row.importRowId(), row.importId(), row.companyId(), row.rowNumber(), + encode(row.sourceValues()), encode(row.overrideValues()), encode(row.normalizedValues()), + encode(row.validationErrors()), row.status().name(), row.workerId(), + timestamp(row.createdAt()), timestamp(row.updatedAt()), row.version() + ); + } + } + + @Override + public Optional findJob(UUID companyId, UUID importId) { + return jdbcTemplate.query( + "SELECT " + JOB_COLUMNS + " FROM worker_import_job WHERE company_id = ? AND import_id = ?", + this::mapJob, + companyId, + importId + ).stream().findFirst(); + } + + @Override + public Optional findByCreateKey(UUID companyId, String keyHash) { + return jdbcTemplate.query( + "SELECT " + JOB_COLUMNS + + " FROM worker_import_job WHERE company_id = ? AND create_idempotency_key_hash = ?", + this::mapJob, + companyId, + keyHash + ).stream().findFirst(); + } + + @Override + public List findRows(UUID companyId, UUID importId, int offset, int limit) { + return jdbcTemplate.query( + "SELECT " + ROW_COLUMNS + + " FROM worker_import_row WHERE company_id = ? AND import_id = ?" + + " ORDER BY row_number LIMIT ? OFFSET ?", + this::mapRow, + companyId, + importId, + limit, + offset + ); + } + + @Override + public List findAllRows(UUID companyId, UUID importId) { + return jdbcTemplate.query( + "SELECT " + ROW_COLUMNS + + " FROM worker_import_row WHERE company_id = ? AND import_id = ? ORDER BY row_number", + this::mapRow, + companyId, + importId + ); + } + + @Override + public boolean existsWorkerByDisplayName(UUID companyId, String displayName) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker WHERE company_id = ? AND LOWER(display_name) = LOWER(?)", + Integer.class, + companyId, + displayName + ); + return count != null && count > 0; + } + + @Override + public boolean updateJob( + UUID companyId, + UUID importId, + long expectedVersion, + WorkerImportStatus status, + Map mappings, + int validRows, + int invalidRows, + int excludedRows, + int committedRows, + String commitKeyHash, + String commitRequestHash, + Instant updatedAt + ) { + return jdbcTemplate.update( + """ + UPDATE worker_import_job + SET status = ?, mapping_json = ?, valid_rows = ?, invalid_rows = ?, + excluded_rows = ?, committed_rows = ?, + last_commit_idempotency_key_hash = COALESCE(?, last_commit_idempotency_key_hash), + last_commit_request_hash = COALESCE(?, last_commit_request_hash), + updated_at = ?, version = version + 1 + WHERE company_id = ? AND import_id = ? AND version = ? + """, + status.name(), encodeMappings(mappings), validRows, invalidRows, + excludedRows, committedRows, commitKeyHash, commitRequestHash, + timestamp(updatedAt), companyId, importId, expectedVersion + ) == 1; + } + + @Override + public void updateRow( + UUID companyId, + UUID importId, + int rowNumber, + Map overrideValues, + Map normalizedValues, + List errors, + WorkerImportRowStatus status, + UUID workerId, + Instant updatedAt + ) { + int updated = jdbcTemplate.update( + """ + UPDATE worker_import_row + SET override_values_json = ?, normalized_values_json = ?, + validation_errors_json = ?, status = ?, worker_id = ?, + updated_at = ?, version = version + 1 + WHERE company_id = ? AND import_id = ? AND row_number = ? + """, + encode(overrideValues), encode(normalizedValues), encode(errors), status.name(), workerId, + timestamp(updatedAt), companyId, importId, rowNumber + ); + if (updated != 1) { + throw new IllegalStateException("worker import row update count must be one"); + } + } + + private WorkerImportJobRecord mapJob(ResultSet resultSet, int rowNumber) throws SQLException { + return new WorkerImportJobRecord( + uuid(resultSet, "import_id"), + uuid(resultSet, "company_id"), + uuid(resultSet, "source_file_id"), + uuid(resultSet, "created_by"), + WorkerImportStatus.valueOf(resultSet.getString("status")), + decodeStringList(resultSet.getString("source_headers_json")), + decodeMappings(resultSet.getString("mapping_json")), + resultSet.getString("create_idempotency_key_hash"), + resultSet.getString("create_request_hash"), + resultSet.getString("last_commit_idempotency_key_hash"), + resultSet.getString("last_commit_request_hash"), + resultSet.getInt("total_rows"), + resultSet.getInt("valid_rows"), + resultSet.getInt("invalid_rows"), + resultSet.getInt("excluded_rows"), + resultSet.getInt("committed_rows"), + instant(resultSet, "source_file_expires_at"), + instant(resultSet, "created_at"), + instant(resultSet, "updated_at"), + resultSet.getLong("version") + ); + } + + private WorkerImportRowRecord mapRow(ResultSet resultSet, int ignored) throws SQLException { + return new WorkerImportRowRecord( + uuid(resultSet, "import_row_id"), + uuid(resultSet, "import_id"), + uuid(resultSet, "company_id"), + resultSet.getInt("row_number"), + decodeStringMap(resultSet.getString("source_values_json")), + decodeStringMap(resultSet.getString("override_values_json")), + decodeStringMap(resultSet.getString("normalized_values_json")), + decodeErrors(resultSet.getString("validation_errors_json")), + WorkerImportRowStatus.valueOf(resultSet.getString("status")), + nullableUuid(resultSet, "worker_id"), + instant(resultSet, "created_at"), + instant(resultSet, "updated_at"), + resultSet.getLong("version") + ); + } + + private String encodeMappings(Map mappings) { + Map encoded = new LinkedHashMap<>(); + mappings.forEach((source, target) -> encoded.put(source, target.key())); + return encode(encoded); + } + + private Map decodeMappings(String json) { + Map result = new LinkedHashMap<>(); + decodeStringMap(json).forEach((source, target) -> result.put(source, WorkerImportField.fromKey(target))); + return result; + } + + @SuppressWarnings("unchecked") + private Map decodeStringMap(String json) { + try { + Map raw = objectMapper.readValue(json, Map.class); + Map result = new LinkedHashMap<>(); + raw.forEach((key, value) -> result.put(key, value == null ? null : value.toString())); + return result; + } catch (JacksonException exception) { + throw new IllegalStateException("stored worker import map cannot be decoded", exception); + } + } + + @SuppressWarnings("unchecked") + private List decodeStringList(String json) { + try { + List raw = objectMapper.readValue(json, List.class); + return raw.stream().map(Object::toString).toList(); + } catch (JacksonException exception) { + throw new IllegalStateException("stored worker import headers cannot be decoded", exception); + } + } + + @SuppressWarnings("unchecked") + private List decodeErrors(String json) { + try { + List> raw = objectMapper.readValue(json, List.class); + List result = new ArrayList<>(); + for (Map value : raw) { + result.add(new ImportValidationError( + String.valueOf(value.get("field")), + String.valueOf(value.get("code")), + String.valueOf(value.get("message")) + )); + } + return result; + } catch (JacksonException exception) { + throw new IllegalStateException("stored worker import errors cannot be decoded", exception); + } + } + + private String encode(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JacksonException exception) { + throw new IllegalStateException("worker import data cannot be encoded", exception); + } + } + + private UUID uuid(ResultSet resultSet, String column) throws SQLException { + Object value = resultSet.getObject(column); + return value instanceof UUID uuid ? uuid : UUID.fromString(value.toString()); + } + + private UUID nullableUuid(ResultSet resultSet, String column) throws SQLException { + Object value = resultSet.getObject(column); + return value == null ? null : value instanceof UUID uuid ? uuid : UUID.fromString(value.toString()); + } + + private Instant instant(ResultSet resultSet, String column) throws SQLException { + return resultSet.getTimestamp(column).toInstant(); + } + + private Timestamp timestamp(Instant instant) { + return Timestamp.from(instant); + } +} diff --git a/src/main/java/com/fowoco/server/workerimport/package-info.java b/src/main/java/com/fowoco/server/workerimport/package-info.java new file mode 100644 index 00000000..a25efd69 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/package-info.java @@ -0,0 +1,4 @@ +/** + * CSV/XLSX worker roster upload, validation, review, and commit workflow. + */ +package com.fowoco.server.workerimport; diff --git a/src/main/resources/db/migration-postgresql/V33__prepare_worker_import_rls.sql b/src/main/resources/db/migration-postgresql/V33__prepare_worker_import_rls.sql new file mode 100644 index 00000000..cd20e11a --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V33__prepare_worker_import_rls.sql @@ -0,0 +1,21 @@ +CREATE POLICY pl_worker_import_job_tenant_isolation + ON public.worker_import_job + FOR ALL + TO PUBLIC + USING ( + company_id = NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); + +CREATE POLICY pl_worker_import_row_tenant_isolation + ON public.worker_import_row + FOR ALL + TO PUBLIC + USING ( + company_id = NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); diff --git a/src/main/resources/db/migration/V32__create_worker_import.sql b/src/main/resources/db/migration/V32__create_worker_import.sql new file mode 100644 index 00000000..4c92d202 --- /dev/null +++ b/src/main/resources/db/migration/V32__create_worker_import.sql @@ -0,0 +1,97 @@ +CREATE TABLE worker_import_job ( + import_id UUID NOT NULL, + company_id UUID NOT NULL, + source_file_id UUID NOT NULL, + created_by UUID NOT NULL, + status VARCHAR(30) NOT NULL, + source_headers_json TEXT NOT NULL, + mapping_json TEXT NOT NULL, + create_idempotency_key_hash VARCHAR(64) NOT NULL, + create_request_hash VARCHAR(64) NOT NULL, + last_commit_idempotency_key_hash VARCHAR(64), + last_commit_request_hash VARCHAR(64), + total_rows INTEGER NOT NULL DEFAULT 0, + valid_rows INTEGER NOT NULL DEFAULT 0, + invalid_rows INTEGER NOT NULL DEFAULT 0, + excluded_rows INTEGER NOT NULL DEFAULT 0, + committed_rows INTEGER NOT NULL DEFAULT 0, + source_file_expires_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT pk_worker_import_job PRIMARY KEY (import_id), + CONSTRAINT uq_worker_import_job_id_company UNIQUE (import_id, company_id), + CONSTRAINT uq_worker_import_job_create_key UNIQUE (company_id, create_idempotency_key_hash), + CONSTRAINT fk_worker_import_job_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_import_job_source_file_company + FOREIGN KEY (source_file_id, company_id) + REFERENCES stored_file (stored_file_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_import_job_creator_company + FOREIGN KEY (created_by, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_worker_import_job_status CHECK (status IN ( + 'UPLOADED', 'MAPPED', 'REVIEW_REQUIRED', 'READY', 'COMMITTED' + )), + CONSTRAINT ck_worker_import_job_create_key_hash + CHECK (CHAR_LENGTH(create_idempotency_key_hash) = 64), + CONSTRAINT ck_worker_import_job_create_request_hash + CHECK (CHAR_LENGTH(create_request_hash) = 64), + CONSTRAINT ck_worker_import_job_commit_hash_pair CHECK ( + (last_commit_idempotency_key_hash IS NULL AND last_commit_request_hash IS NULL) + OR ( + CHAR_LENGTH(last_commit_idempotency_key_hash) = 64 + AND CHAR_LENGTH(last_commit_request_hash) = 64 + ) + ), + CONSTRAINT ck_worker_import_job_counts CHECK ( + total_rows >= 0 AND valid_rows >= 0 AND invalid_rows >= 0 + AND excluded_rows >= 0 AND committed_rows >= 0 + AND valid_rows + invalid_rows + excluded_rows + committed_rows <= total_rows + ), + CONSTRAINT ck_worker_import_job_version CHECK (version >= 0), + CONSTRAINT ck_worker_import_job_time_order CHECK ( + updated_at >= created_at AND source_file_expires_at > created_at + ) +); + +CREATE TABLE worker_import_row ( + import_row_id UUID NOT NULL, + import_id UUID NOT NULL, + company_id UUID NOT NULL, + row_number INTEGER NOT NULL, + source_values_json TEXT NOT NULL, + override_values_json TEXT NOT NULL, + normalized_values_json TEXT NOT NULL, + validation_errors_json TEXT NOT NULL, + status VARCHAR(20) NOT NULL, + worker_id UUID, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT pk_worker_import_row PRIMARY KEY (import_row_id), + CONSTRAINT uq_worker_import_row_id_company UNIQUE (import_row_id, company_id), + CONSTRAINT uq_worker_import_row_number UNIQUE (import_id, row_number), + CONSTRAINT fk_worker_import_row_job_company + FOREIGN KEY (import_id, company_id) + REFERENCES worker_import_job (import_id, company_id) ON DELETE CASCADE, + CONSTRAINT fk_worker_import_row_worker_company + FOREIGN KEY (worker_id, company_id) + REFERENCES worker (worker_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_worker_import_row_number CHECK (row_number >= 2), + CONSTRAINT ck_worker_import_row_status CHECK (status IN ( + 'PENDING', 'VALID', 'INVALID', 'EXCLUDED', 'COMMITTED' + )), + CONSTRAINT ck_worker_import_row_worker_status CHECK ( + (status = 'COMMITTED' AND worker_id IS NOT NULL) + OR (status <> 'COMMITTED' AND worker_id IS NULL) + ), + CONSTRAINT ck_worker_import_row_version CHECK (version >= 0), + CONSTRAINT ck_worker_import_row_time_order CHECK (updated_at >= created_at) +); + +CREATE INDEX idx_worker_import_job_company_updated + ON worker_import_job (company_id, updated_at DESC, import_id); + +CREATE INDEX idx_worker_import_row_job_status + ON worker_import_row (company_id, import_id, status, row_number); From 7480bd010b670a64f5adf6ceef2c60e62a24495d Mon Sep 17 00:00:00 2001 From: hywznn Date: Fri, 7 Aug 2026 18:07:31 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(import):=20=ED=96=89=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=C2=B7=EC=88=98=EC=A0=95=C2=B7=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/audit/domain/AuditAction.java | 8 +- .../server/audit/domain/AuditTargetType.java | 3 +- .../api/WorkerImportCommitRequest.java | 14 + .../api/WorkerImportController.java | 161 +++++ .../api/WorkerImportMappingRequest.java | 11 + .../api/WorkerImportResponse.java | 49 ++ .../api/WorkerImportRowPatchRequest.java | 12 + .../api/WorkerImportRowResponse.java | 26 + .../api/WorkerImportRowsRequest.java | 12 + .../api/WorkerImportValidateRequest.java | 6 + .../application/WorkerImportService.java | 642 ++++++++++++++++++ src/main/resources/application.yaml | 2 + 12 files changed, 944 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/fowoco/server/workerimport/api/WorkerImportCommitRequest.java create mode 100644 src/main/java/com/fowoco/server/workerimport/api/WorkerImportController.java create mode 100644 src/main/java/com/fowoco/server/workerimport/api/WorkerImportMappingRequest.java create mode 100644 src/main/java/com/fowoco/server/workerimport/api/WorkerImportResponse.java create mode 100644 src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowPatchRequest.java create mode 100644 src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowResponse.java create mode 100644 src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowsRequest.java create mode 100644 src/main/java/com/fowoco/server/workerimport/api/WorkerImportValidateRequest.java create mode 100644 src/main/java/com/fowoco/server/workerimport/application/WorkerImportService.java diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java index 2bf6c92f..ba76ccaa 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -31,5 +31,11 @@ public enum AuditAction { DOCUMENT_OCR_FAILED, DOCUMENT_OCR_RESULT_VIEWED, DOCUMENT_OCR_APPROVED, - DOCUMENT_OCR_REJECTED + DOCUMENT_OCR_REJECTED, + WORKER_IMPORT_CREATED, + WORKER_IMPORT_MAPPING_UPDATED, + WORKER_IMPORT_VALIDATED, + WORKER_IMPORT_ROWS_UPDATED, + WORKER_IMPORT_COMMITTED, + WORKER_IMPORT_RETRIED } diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java index 864162fb..ba6f563f 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java @@ -11,5 +11,6 @@ public enum AuditTargetType { AI_RUN, OUTBOX_EVENT, USER_ACCOUNT, - DOCUMENT_OCR_RUN + DOCUMENT_OCR_RUN, + WORKER_IMPORT } diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportCommitRequest.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportCommitRequest.java new file mode 100644 index 00000000..6c516820 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportCommitRequest.java @@ -0,0 +1,14 @@ +package com.fowoco.server.workerimport.api; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.util.Set; + +public record WorkerImportCommitRequest( + @NotNull Long expectedVersion, + Set<@Min(2) Integer> selectedRowNumbers +) { + public WorkerImportCommitRequest { + selectedRowNumbers = selectedRowNumbers == null ? Set.of() : Set.copyOf(selectedRowNumbers); + } +} diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportController.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportController.java new file mode 100644 index 00000000..9c766a48 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportController.java @@ -0,0 +1,161 @@ +package com.fowoco.server.workerimport.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.workerimport.application.WorkerImportRowPatch; +import com.fowoco.server.workerimport.application.WorkerImportService; +import com.fowoco.server.workerimport.application.error.WorkerImportErrorCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import java.io.IOException; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Worker Import", description = "CSV/XLSX 근로자 명단 검토·등록") +@RestController +@RequestMapping("/api/v1/imports") +@SecurityRequirement(name = "bearerAuth") +@Validated +public class WorkerImportController { + + private final WorkerImportService service; + private final ActorContextProvider actorContextProvider; + + public WorkerImportController(WorkerImportService service, ActorContextProvider actorContextProvider) { + this.service = service; + this.actorContextProvider = actorContextProvider; + } + + @Operation(summary = "근로자 명단 가져오기 생성", description = "CSV/XLSX를 파싱해 검토 작업을 만듭니다. 아직 근로자는 등록하지 않습니다.") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public ResponseEntity create( + @RequestPart("file") MultipartFile file, + @RequestHeader("Idempotency-Key") + @Size(min = 8, max = 100) String idempotencyKey, + HttpServletRequest request + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + try { + var result = service.create( + file.getOriginalFilename(), file.getBytes(), idempotencyKey, + actor, RequestMetadata.from(request) + ); + return ResponseEntity.status(HttpStatus.CREATED).body(WorkerImportResponse.from(result)); + } catch (IOException exception) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_INVALID); + } + } + + @Operation(summary = "가져오기 작업 조회", description = "행별 검증 결과와 등록 진행 상황을 조회합니다.") + @GetMapping(path = "/{importId}", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public WorkerImportResponse find( + @PathVariable UUID importId, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "100") @Min(1) @Max(100) int size + ) { + return WorkerImportResponse.from(service.find( + importId, page, size, actorContextProvider.requireCurrentActor() + )); + } + + @Operation(summary = "가져오기 열 연결 저장", description = "업로드 파일의 열을 Worker 필드에 연결합니다.") + @PutMapping(path = "/{importId}/mappings", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public WorkerImportResponse saveMappings( + @PathVariable UUID importId, + @Valid @RequestBody WorkerImportMappingRequest body, + HttpServletRequest request + ) { + return WorkerImportResponse.from(service.saveMappings( + importId, body.expectedVersion(), body.mappings(), + actorContextProvider.requireCurrentActor(), RequestMetadata.from(request) + )); + } + + @Operation(summary = "가져오기 행 검증", description = "연결된 값을 날짜·필수값·중복 후보 규칙으로 검증합니다.") + @PostMapping(path = "/{importId}/validate", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public WorkerImportResponse validate( + @PathVariable UUID importId, + @Valid @RequestBody WorkerImportValidateRequest body, + HttpServletRequest request + ) { + return WorkerImportResponse.from(service.validate( + importId, body.expectedVersion(), actorContextProvider.requireCurrentActor(), + RequestMetadata.from(request), false + )); + } + + @Operation(summary = "가져오기 오류 행 수정·제외", description = "시스템 필드 값을 고치거나 등록 대상에서 제외합니다.") + @PatchMapping(path = "/{importId}/rows", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public WorkerImportResponse patchRows( + @PathVariable UUID importId, + @Valid @RequestBody WorkerImportRowsRequest body, + HttpServletRequest request + ) { + var patches = body.rows().stream() + .map(row -> new WorkerImportRowPatch(row.rowNumber(), row.excluded(), row.values())) + .toList(); + return WorkerImportResponse.from(service.patchRows( + importId, body.expectedVersion(), patches, actorContextProvider.requireCurrentActor(), + RequestMetadata.from(request) + )); + } + + @Operation(summary = "정상 행 등록 확정", description = "선택한 VALID 행만 Worker로 등록하며 재호출로 중복 생성하지 않습니다.") + @PostMapping(path = "/{importId}/commit", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public WorkerImportResponse commit( + @PathVariable UUID importId, + @RequestHeader("Idempotency-Key") @Size(min = 8, max = 100) String idempotencyKey, + @Valid @RequestBody WorkerImportCommitRequest body, + HttpServletRequest request + ) { + return WorkerImportResponse.from(service.commit( + importId, body.expectedVersion(), body.selectedRowNumbers(), idempotencyKey, + actorContextProvider.requireCurrentActor(), RequestMetadata.from(request) + )); + } + + @Operation(summary = "오류 행 재검증", description = "수정된 행을 포함해 아직 등록하지 않은 행을 다시 검증합니다.") + @PostMapping(path = "/{importId}/retry", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public WorkerImportResponse retry( + @PathVariable UUID importId, + @Valid @RequestBody WorkerImportValidateRequest body, + HttpServletRequest request + ) { + return WorkerImportResponse.from(service.validate( + importId, body.expectedVersion(), actorContextProvider.requireCurrentActor(), + RequestMetadata.from(request), true + )); + } +} diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportMappingRequest.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportMappingRequest.java new file mode 100644 index 00000000..54863eda --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportMappingRequest.java @@ -0,0 +1,11 @@ +package com.fowoco.server.workerimport.api; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.Map; + +public record WorkerImportMappingRequest( + @NotNull Long expectedVersion, + @NotEmpty Map mappings +) { +} diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportResponse.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportResponse.java new file mode 100644 index 00000000..4f3784d2 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportResponse.java @@ -0,0 +1,49 @@ +package com.fowoco.server.workerimport.api; + +import com.fowoco.server.workerimport.application.WorkerImportView; +import com.fowoco.server.workerimport.domain.WorkerImportStatus; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record WorkerImportResponse( + UUID importId, + UUID sourceFileId, + WorkerImportStatus status, + List sourceHeaders, + Map mappings, + int totalRows, + int validRows, + int invalidRows, + int excludedRows, + int committedRows, + Instant sourceFileExpiresAt, + long version, + List rows, + int page, + int size +) { + static WorkerImportResponse from(WorkerImportView view) { + Map mappings = new LinkedHashMap<>(); + view.job().mappings().forEach((source, target) -> mappings.put(source, target.key())); + return new WorkerImportResponse( + view.job().importId(), + view.job().sourceFileId(), + view.job().status(), + view.job().sourceHeaders(), + mappings, + view.job().totalRows(), + view.job().validRows(), + view.job().invalidRows(), + view.job().excludedRows(), + view.job().committedRows(), + view.job().sourceFileExpiresAt(), + view.job().version(), + view.rows().stream().map(WorkerImportRowResponse::from).toList(), + view.page(), + view.size() + ); + } +} diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowPatchRequest.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowPatchRequest.java new file mode 100644 index 00000000..ccf94df9 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowPatchRequest.java @@ -0,0 +1,12 @@ +package com.fowoco.server.workerimport.api; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.util.Map; + +public record WorkerImportRowPatchRequest( + @Min(2) int rowNumber, + Boolean excluded, + @NotNull Map values +) { +} diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowResponse.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowResponse.java new file mode 100644 index 00000000..3487e655 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowResponse.java @@ -0,0 +1,26 @@ +package com.fowoco.server.workerimport.api; + +import com.fowoco.server.workerimport.application.ImportValidationError; +import com.fowoco.server.workerimport.application.WorkerImportRowRecord; +import com.fowoco.server.workerimport.domain.WorkerImportRowStatus; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record WorkerImportRowResponse( + int rowNumber, + Map sourceValues, + Map overrideValues, + Map normalizedValues, + WorkerImportRowStatus status, + List errors, + UUID workerId, + long version +) { + static WorkerImportRowResponse from(WorkerImportRowRecord row) { + return new WorkerImportRowResponse( + row.rowNumber(), row.sourceValues(), row.overrideValues(), row.normalizedValues(), + row.status(), row.validationErrors(), row.workerId(), row.version() + ); + } +} diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowsRequest.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowsRequest.java new file mode 100644 index 00000000..5293198d --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowsRequest.java @@ -0,0 +1,12 @@ +package com.fowoco.server.workerimport.api; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record WorkerImportRowsRequest( + @NotNull Long expectedVersion, + @NotEmpty List<@Valid WorkerImportRowPatchRequest> rows +) { +} diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportValidateRequest.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportValidateRequest.java new file mode 100644 index 00000000..cb309444 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportValidateRequest.java @@ -0,0 +1,6 @@ +package com.fowoco.server.workerimport.api; + +import jakarta.validation.constraints.NotNull; + +public record WorkerImportValidateRequest(@NotNull Long expectedVersion) { +} diff --git a/src/main/java/com/fowoco/server/workerimport/application/WorkerImportService.java b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportService.java new file mode 100644 index 00000000..7413cc78 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportService.java @@ -0,0 +1,642 @@ +package com.fowoco.server.workerimport.application; + +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.common.time.DatabaseTimestamp; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.file.application.port.FileStorage; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.StoredFile; +import com.fowoco.server.worker.application.port.WorkerRepository; +import com.fowoco.server.worker.domain.Worker; +import com.fowoco.server.workerimport.application.error.WorkerImportErrorCode; +import com.fowoco.server.workerimport.application.port.WorkerImportFileParser; +import com.fowoco.server.workerimport.application.port.WorkerImportRepository; +import com.fowoco.server.workerimport.domain.WorkerImportField; +import com.fowoco.server.workerimport.domain.WorkerImportRowStatus; +import com.fowoco.server.workerimport.domain.WorkerImportStatus; +import java.io.ByteArrayInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerImportService { + + private static final String AUDIT_EVENT_VERSION = "1"; + private static final long MAX_FILE_SIZE = 5L * 1024 * 1024; + private static final int MAX_PAGE_SIZE = 100; + + private final WorkerImportRepository repository; + private final WorkerImportFileParser parser; + private final FileStorage fileStorage; + private final StoredFileRepository storedFileRepository; + private final WorkerRepository workerRepository; + private final AuditEventRepository auditRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final UuidGenerator uuidGenerator; + private final Clock clock; + private final Duration sourceRetention; + + public WorkerImportService( + WorkerImportRepository repository, + WorkerImportFileParser parser, + FileStorage fileStorage, + StoredFileRepository storedFileRepository, + WorkerRepository workerRepository, + AuditEventRepository auditRepository, + TenantDatabaseContext tenantDatabaseContext, + UuidGenerator uuidGenerator, + Clock clock, + @Value("${app.worker-import.source-retention:7d}") Duration sourceRetention + ) { + this.repository = repository; + this.parser = parser; + this.fileStorage = fileStorage; + this.storedFileRepository = storedFileRepository; + this.workerRepository = workerRepository; + this.auditRepository = auditRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + this.sourceRetention = sourceRetention; + } + + @Transactional + public WorkerImportView create( + String fileName, + byte[] content, + String idempotencyKey, + ActorContext actor, + RequestMetadata metadata + ) { + bindTenant(actor); + if (content == null || content.length == 0) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_EMPTY); + } + if (content.length > MAX_FILE_SIZE) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_TOO_LARGE); + } + if (fileName == null || fileName.isBlank()) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_TYPE_UNSUPPORTED); + } + String normalizedKey = normalizeIdempotencyKey(idempotencyKey); + String keyHash = sha256(normalizedKey.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String requestHash = requestHash(fileName, content); + var existing = repository.findByCreateKey(actor.companyId(), keyHash); + if (existing.isPresent()) { + if (!existing.get().createRequestHash().equals(requestHash)) { + throw new ApiException(WorkerImportErrorCode.IMPORT_IDEMPOTENCY_CONFLICT); + } + return view(existing.get(), 0, 100); + } + + ParsedWorkerImport parsed = parser.parse(fileName, content); + Instant now = DatabaseTimestamp.now(clock); + UUID sourceFileId = uuidGenerator.generate(); + String storageKey = sourceFileId.toString(); + String safeMimeType = fileName.toLowerCase(Locale.ROOT).endsWith(".csv") + ? "text/csv" + : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + StoredFile sourceFile = StoredFile.create( + sourceFileId, + actor.companyId(), + fileName, + safeMimeType, + content.length, + "WORKER_IMPORT_SOURCE", + null, + null, + storageKey, + now + ); + fileStorage.store(storageKey, new ByteArrayInputStream(content), content.length, safeMimeType); + storedFileRepository.insert(sourceFile); + + UUID importId = uuidGenerator.generate(); + WorkerImportJobRecord job = new WorkerImportJobRecord( + importId, + actor.companyId(), + sourceFileId, + actor.actorId(), + WorkerImportStatus.UPLOADED, + parsed.headers(), + Map.of(), + keyHash, + requestHash, + null, + null, + parsed.rows().size(), + 0, + 0, + 0, + 0, + now.plus(sourceRetention), + now, + now, + 0 + ); + List rows = new ArrayList<>(); + for (int index = 0; index < parsed.rows().size(); index++) { + rows.add(new WorkerImportRowRecord( + uuidGenerator.generate(), importId, actor.companyId(), index + 2, + parsed.rows().get(index), Map.of(), Map.of(), List.of(), + WorkerImportRowStatus.PENDING, null, now, now, 0 + )); + } + repository.insert(job, rows); + appendAudit(actor, AuditAction.WORKER_IMPORT_CREATED, importId, "근로자 명단 가져오기 생성", metadata, now); + return new WorkerImportView(job, rows.stream().limit(100).toList(), 0, 100); + } + + @Transactional(readOnly = true) + public WorkerImportView find(UUID importId, int page, int size, ActorContext actor) { + bindTenant(actor); + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_INVALID); + } + WorkerImportJobRecord job = requireJob(actor.companyId(), importId); + return view(job, page, size); + } + + @Transactional + public WorkerImportView saveMappings( + UUID importId, + long expectedVersion, + Map requestedMappings, + ActorContext actor, + RequestMetadata metadata + ) { + bindTenant(actor); + WorkerImportJobRecord job = requireVersion(actor.companyId(), importId, expectedVersion); + if (job.status() == WorkerImportStatus.COMMITTED) { + throw new ApiException(WorkerImportErrorCode.IMPORT_STATE_INVALID); + } + Map mappings = validateMappings(job.sourceHeaders(), requestedMappings); + Instant now = DatabaseTimestamp.nowNotBefore(clock, job.createdAt()); + List rows = repository.findAllRows(actor.companyId(), importId); + for (WorkerImportRowRecord row : rows) { + if (row.status() != WorkerImportRowStatus.COMMITTED && row.status() != WorkerImportRowStatus.EXCLUDED) { + repository.updateRow( + actor.companyId(), importId, row.rowNumber(), row.overrideValues(), Map.of(), List.of(), + WorkerImportRowStatus.PENDING, null, now + ); + } + } + Counts counts = counts(repository.findAllRows(actor.companyId(), importId)); + updateJob(job, WorkerImportStatus.MAPPED, mappings, counts, null, null, now); + appendAudit(actor, AuditAction.WORKER_IMPORT_MAPPING_UPDATED, importId, "가져오기 열 연결 수정", metadata, now); + return view(requireJob(actor.companyId(), importId), 0, 100); + } + + @Transactional + public WorkerImportView patchRows( + UUID importId, + long expectedVersion, + List patches, + ActorContext actor, + RequestMetadata metadata + ) { + bindTenant(actor); + WorkerImportJobRecord job = requireVersion(actor.companyId(), importId, expectedVersion); + if (job.status() == WorkerImportStatus.COMMITTED || job.mappings().isEmpty()) { + throw new ApiException(WorkerImportErrorCode.IMPORT_STATE_INVALID); + } + Map rows = repository.findAllRows(actor.companyId(), importId).stream() + .collect(Collectors.toMap(WorkerImportRowRecord::rowNumber, Function.identity())); + if (patches.stream().map(WorkerImportRowPatch::rowNumber).distinct().count() != patches.size()) { + throw new ApiException(WorkerImportErrorCode.IMPORT_MAPPING_INVALID); + } + Instant now = DatabaseTimestamp.nowNotBefore(clock, job.createdAt()); + for (WorkerImportRowPatch patch : patches) { + WorkerImportRowRecord row = rows.get(patch.rowNumber()); + if (row == null || row.status() == WorkerImportRowStatus.COMMITTED) { + throw new ApiException(WorkerImportErrorCode.IMPORT_MAPPING_INVALID); + } + Map overrides = new LinkedHashMap<>(row.overrideValues()); + patch.values().forEach((key, value) -> { + WorkerImportField.fromKey(key); + overrides.put(key, normalizeCell(value)); + }); + boolean excluded = patch.excluded() != null ? patch.excluded() : row.status() == WorkerImportRowStatus.EXCLUDED; + repository.updateRow( + actor.companyId(), importId, row.rowNumber(), overrides, Map.of(), List.of(), + excluded ? WorkerImportRowStatus.EXCLUDED : WorkerImportRowStatus.PENDING, + null, + now + ); + } + Counts counts = counts(repository.findAllRows(actor.companyId(), importId)); + updateJob(job, WorkerImportStatus.MAPPED, job.mappings(), counts, null, null, now); + appendAudit(actor, AuditAction.WORKER_IMPORT_ROWS_UPDATED, importId, "가져오기 행 수정", metadata, now); + return view(requireJob(actor.companyId(), importId), 0, 100); + } + + @Transactional + public WorkerImportView validate( + UUID importId, + long expectedVersion, + ActorContext actor, + RequestMetadata metadata, + boolean retry + ) { + bindTenant(actor); + WorkerImportJobRecord job = requireVersion(actor.companyId(), importId, expectedVersion); + if (job.mappings().isEmpty() || job.status() == WorkerImportStatus.COMMITTED) { + throw new ApiException(WorkerImportErrorCode.IMPORT_STATE_INVALID); + } + List rows = repository.findAllRows(actor.companyId(), importId); + Map names = rows.stream() + .filter(row -> row.status() != WorkerImportRowStatus.EXCLUDED + && row.status() != WorkerImportRowStatus.COMMITTED) + .map(row -> effectiveValues(row, job.mappings()).get(WorkerImportField.DISPLAY_NAME.key())) + .filter(Objects::nonNull) + .map(value -> value.strip().toLowerCase(Locale.ROOT)) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + Instant now = DatabaseTimestamp.nowNotBefore(clock, job.createdAt()); + for (WorkerImportRowRecord row : rows) { + if (row.status() == WorkerImportRowStatus.EXCLUDED || row.status() == WorkerImportRowStatus.COMMITTED) { + continue; + } + Map normalized = normalize(effectiveValues(row, job.mappings())); + List errors = validateRow(normalized, names, actor.companyId()); + repository.updateRow( + actor.companyId(), importId, row.rowNumber(), row.overrideValues(), normalized, errors, + errors.isEmpty() ? WorkerImportRowStatus.VALID : WorkerImportRowStatus.INVALID, + null, + now + ); + } + Counts counts = counts(repository.findAllRows(actor.companyId(), importId)); + WorkerImportStatus status = counts.invalid() > 0 || counts.valid() == 0 + ? WorkerImportStatus.REVIEW_REQUIRED + : WorkerImportStatus.READY; + updateJob(job, status, job.mappings(), counts, null, null, now); + appendAudit( + actor, + retry ? AuditAction.WORKER_IMPORT_RETRIED : AuditAction.WORKER_IMPORT_VALIDATED, + importId, + retry ? "오류 행 재검증" : "가져오기 행 검증", + metadata, + now + ); + return view(requireJob(actor.companyId(), importId), 0, 100); + } + + @Transactional + public WorkerImportView commit( + UUID importId, + long expectedVersion, + Set selectedRows, + String idempotencyKey, + ActorContext actor, + RequestMetadata metadata + ) { + bindTenant(actor); + WorkerImportJobRecord job = requireJob(actor.companyId(), importId); + String keyHash = sha256(normalizeIdempotencyKey(idempotencyKey) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String requestHash = sha256(selectedRows.stream().sorted().map(String::valueOf) + .collect(Collectors.joining(",")) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + if (keyHash.equals(job.lastCommitIdempotencyKeyHash())) { + if (!requestHash.equals(job.lastCommitRequestHash())) { + throw new ApiException(WorkerImportErrorCode.IMPORT_IDEMPOTENCY_CONFLICT); + } + return view(job, 0, 100); + } + if (job.version() != expectedVersion) { + throw new ApiException(WorkerImportErrorCode.IMPORT_VERSION_CONFLICT); + } + if (job.status() != WorkerImportStatus.READY && job.status() != WorkerImportStatus.REVIEW_REQUIRED) { + throw new ApiException(WorkerImportErrorCode.IMPORT_STATE_INVALID); + } + List rows = repository.findAllRows(actor.companyId(), importId); + List candidates = rows.stream() + .filter(row -> row.status() == WorkerImportRowStatus.VALID) + .filter(row -> selectedRows.isEmpty() || selectedRows.contains(row.rowNumber())) + .toList(); + if (candidates.isEmpty()) { + throw new ApiException(WorkerImportErrorCode.IMPORT_NO_VALID_ROWS); + } + Instant now = DatabaseTimestamp.nowNotBefore(clock, job.createdAt()); + for (WorkerImportRowRecord row : candidates) { + Map values = row.normalizedValues(); + UUID workerId = uuidGenerator.generate(); + Worker worker = Worker.create( + workerId, + actor.companyId(), + values.get(WorkerImportField.DISPLAY_NAME.key()), + values.get(WorkerImportField.NATIONALITY_CODE.key()), + values.get(WorkerImportField.PREFERRED_LANGUAGE.key()), + values.get(WorkerImportField.VISA_TYPE.key()), + date(values, WorkerImportField.STAY_EXPIRY_DATE), + date(values, WorkerImportField.CONTRACT_START_DATE), + date(values, WorkerImportField.CONTRACT_END_DATE), + date(values, WorkerImportField.EMPLOYMENT_PERMIT_END_DATE), + date(values, WorkerImportField.EMPLOYMENT_ACTIVITY_END_DATE), + now + ); + workerRepository.insert(worker); + repository.updateRow( + actor.companyId(), importId, row.rowNumber(), row.overrideValues(), row.normalizedValues(), + List.of(), WorkerImportRowStatus.COMMITTED, workerId, now + ); + } + Counts counts = counts(repository.findAllRows(actor.companyId(), importId)); + WorkerImportStatus status = counts.invalid() > 0 + ? WorkerImportStatus.REVIEW_REQUIRED + : counts.valid() > 0 ? WorkerImportStatus.READY : WorkerImportStatus.COMMITTED; + updateJob(job, status, job.mappings(), counts, keyHash, requestHash, now); + appendAudit(actor, AuditAction.WORKER_IMPORT_COMMITTED, importId, "정상 행 근로자 등록 확정", metadata, now); + return view(requireJob(actor.companyId(), importId), 0, 100); + } + + private Map validateMappings( + List headers, + Map requested + ) { + if (requested == null || requested.isEmpty()) { + throw new ApiException(WorkerImportErrorCode.IMPORT_MAPPING_INVALID); + } + Set sourceHeaders = new HashSet<>(headers); + Set targets = new HashSet<>(); + Map mappings = new LinkedHashMap<>(); + try { + requested.forEach((source, targetKey) -> { + if (!sourceHeaders.contains(source)) { + throw new IllegalArgumentException("unknown source"); + } + WorkerImportField target = WorkerImportField.fromKey(targetKey); + if (!targets.add(target)) { + throw new IllegalArgumentException("duplicate target"); + } + mappings.put(source, target); + }); + } catch (IllegalArgumentException exception) { + throw new ApiException(WorkerImportErrorCode.IMPORT_MAPPING_INVALID); + } + if (!targets.contains(WorkerImportField.DISPLAY_NAME)) { + throw new ApiException(WorkerImportErrorCode.IMPORT_MAPPING_INVALID); + } + return mappings; + } + + private Map effectiveValues( + WorkerImportRowRecord row, + Map mappings + ) { + Map result = new LinkedHashMap<>(); + mappings.forEach((source, target) -> result.put(target.key(), row.sourceValues().get(source))); + result.putAll(row.overrideValues()); + return result; + } + + private Map normalize(Map values) { + Map normalized = new LinkedHashMap<>(); + values.forEach((key, value) -> { + String normalizedValue = normalizeCell(value); + if (WorkerImportField.NATIONALITY_CODE.key().equals(key) && normalizedValue != null) { + normalizedValue = normalizedValue.toUpperCase(Locale.ROOT); + } + if (WorkerImportField.VISA_TYPE.key().equals(key) && normalizedValue != null) { + normalizedValue = normalizedValue.toUpperCase(Locale.ROOT); + } + normalized.put(key, normalizedValue); + }); + return normalized; + } + + private List validateRow( + Map values, + Map names, + UUID companyId + ) { + List errors = new ArrayList<>(); + String displayName = values.get(WorkerImportField.DISPLAY_NAME.key()); + if (displayName == null || displayName.isBlank()) { + errors.add(error(WorkerImportField.DISPLAY_NAME, "REQUIRED", "표시 이름을 입력해 주세요.")); + } else if (displayName.length() > 120) { + errors.add(error(WorkerImportField.DISPLAY_NAME, "TOO_LONG", "표시 이름은 120자 이하여야 합니다.")); + } else if (names.getOrDefault(displayName.toLowerCase(Locale.ROOT), 0L) > 1 + || repository.existsWorkerByDisplayName(companyId, displayName)) { + errors.add(error(WorkerImportField.DISPLAY_NAME, "DUPLICATE_CANDIDATE", "같은 표시 이름의 근로자가 있습니다.")); + } + String nationality = values.get(WorkerImportField.NATIONALITY_CODE.key()); + if (nationality != null && !nationality.matches("[A-Z]{2}")) { + errors.add(error(WorkerImportField.NATIONALITY_CODE, "INVALID_FORMAT", "국적은 ISO 2자리 코드로 입력해 주세요.")); + } + bounded(values, WorkerImportField.PREFERRED_LANGUAGE, 20, errors); + bounded(values, WorkerImportField.VISA_TYPE, 20, errors); + for (WorkerImportField dateField : List.of( + WorkerImportField.STAY_EXPIRY_DATE, + WorkerImportField.CONTRACT_START_DATE, + WorkerImportField.CONTRACT_END_DATE, + WorkerImportField.EMPLOYMENT_PERMIT_END_DATE, + WorkerImportField.EMPLOYMENT_ACTIVITY_END_DATE + )) { + parseDate(values.get(dateField.key()), dateField, errors); + } + LocalDate start = safeDate(values.get(WorkerImportField.CONTRACT_START_DATE.key())); + LocalDate end = safeDate(values.get(WorkerImportField.CONTRACT_END_DATE.key())); + if (start != null && end != null && end.isBefore(start)) { + errors.add(error(WorkerImportField.CONTRACT_END_DATE, "DATE_ORDER", "계약 종료일은 시작일보다 빠를 수 없습니다.")); + } + return List.copyOf(errors); + } + + private void bounded( + Map values, + WorkerImportField field, + int max, + List errors + ) { + String value = values.get(field.key()); + if (value != null && value.length() > max) { + errors.add(error(field, "TOO_LONG", field.key() + " 값이 너무 깁니다.")); + } + } + + private void parseDate(String value, WorkerImportField field, List errors) { + if (value == null) { + return; + } + try { + LocalDate.parse(value); + } catch (DateTimeParseException exception) { + errors.add(error(field, "INVALID_DATE", "날짜는 YYYY-MM-DD 형식이어야 합니다.")); + } + } + + private LocalDate safeDate(String value) { + try { + return value == null ? null : LocalDate.parse(value); + } catch (DateTimeParseException exception) { + return null; + } + } + + private LocalDate date(Map values, WorkerImportField field) { + String value = values.get(field.key()); + return value == null ? null : LocalDate.parse(value); + } + + private ImportValidationError error(WorkerImportField field, String code, String message) { + return new ImportValidationError(field.key(), code, message); + } + + private String normalizeCell(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.strip(); + if (normalized.length() > 500 || normalized.matches("^[=+\\-@].*")) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FORMULA_NOT_ALLOWED); + } + return normalized; + } + + private Counts counts(List rows) { + int valid = 0; + int invalid = 0; + int excluded = 0; + int committed = 0; + for (WorkerImportRowRecord row : rows) { + switch (row.status()) { + case VALID -> valid++; + case INVALID -> invalid++; + case EXCLUDED -> excluded++; + case COMMITTED -> committed++; + case PENDING -> { } + } + } + return new Counts(valid, invalid, excluded, committed); + } + + private void updateJob( + WorkerImportJobRecord job, + WorkerImportStatus status, + Map mappings, + Counts counts, + String commitKeyHash, + String commitRequestHash, + Instant now + ) { + boolean updated = repository.updateJob( + job.companyId(), job.importId(), job.version(), status, mappings, + counts.valid(), counts.invalid(), counts.excluded(), counts.committed(), + commitKeyHash, commitRequestHash, now + ); + if (!updated) { + throw new ApiException(WorkerImportErrorCode.IMPORT_VERSION_CONFLICT); + } + } + + private WorkerImportView view(WorkerImportJobRecord job, int page, int size) { + return new WorkerImportView( + job, + repository.findRows(job.companyId(), job.importId(), page * size, size), + page, + size + ); + } + + private WorkerImportJobRecord requireJob(UUID companyId, UUID importId) { + return repository.findJob(companyId, importId) + .orElseThrow(() -> new ApiException(WorkerImportErrorCode.IMPORT_NOT_FOUND)); + } + + private WorkerImportJobRecord requireVersion(UUID companyId, UUID importId, long expectedVersion) { + WorkerImportJobRecord job = requireJob(companyId, importId); + if (job.version() != expectedVersion) { + throw new ApiException(WorkerImportErrorCode.IMPORT_VERSION_CONFLICT); + } + return job; + } + + private String normalizeIdempotencyKey(String value) { + if (value == null || value.strip().length() < 8 || value.strip().length() > 100) { + throw new ApiException(WorkerImportErrorCode.IMPORT_FILE_INVALID); + } + return value.strip(); + } + + private String requestHash(String fileName, byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(fileName.strip().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(content); + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } + + private String sha256(byte[] value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } + + private void appendAudit( + ActorContext actor, + AuditAction action, + UUID importId, + String summary, + RequestMetadata metadata, + Instant now + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), actor.companyId(), ActorType.HR_USER, actor.actorId(), + actor.roles().stream().min(Comparator.comparingInt(this::rolePriority)).orElseThrow(), + action, AuditTargetType.WORKER_IMPORT, importId, metadata.requestId(), metadata.traceId(), + AUDIT_EVENT_VERSION, summary, now + )); + } + + private int rolePriority(UserRole role) { + return switch (role) { + case ADMIN -> 0; + case HR -> 1; + case VIEWER -> 2; + }; + } + + private void bindTenant(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + } + + private record Counts(int valid, int invalid, int excluded, int committed) { + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 773e2128..3e149991 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -89,6 +89,8 @@ app: enabled: ${DOCUMENT_OCR_ENABLED:false} encryption-key-base64: ${OCR_RESULT_ENCRYPTION_KEY_BASE64:} key-version: ${OCR_RESULT_KEY_VERSION:local-v1} + worker-import: + source-retention: ${WORKER_IMPORT_SOURCE_RETENTION:7d} ai-run: sse: timeout: ${AI_RUN_SSE_TIMEOUT:5m} From f18231eb85fc91b3251c0e0169fe1a5a65621a17 Mon Sep 17 00:00:00 2001 From: hywznn Date: Fri, 7 Aug 2026 18:07:39 +0900 Subject: [PATCH 3/9] =?UTF-8?q?test(import):=20=EB=AA=85=EB=8B=A8=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=C2=B7=EB=A9=B1=EB=93=B1=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=EA=B3=BC=20PostgreSQL=20=EA=B2=A9=EB=A6=AC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/PostgreSqlMigrationTests.java | 39 ++- .../PostgreSqlWorkerImportRlsTest.java | 175 ++++++++++ .../WorkerImportApiIntegrationTest.java | 299 ++++++++++++++++++ .../DefaultWorkerImportFileParserTest.java | 73 +++++ 4 files changed, 582 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/fowoco/server/workerimport/PostgreSqlWorkerImportRlsTest.java create mode 100644 src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java create mode 100644 src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 926d632a..95f4ac16 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -105,7 +105,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { "worker_document_upload_idempotency", "user_agreement_consent", "password_reset_token", - "document_ocr_run" + "document_ocr_run", + "worker_import_job", + "worker_import_row" ); assertThat(columnSpecs(connection, "company")) @@ -302,6 +304,23 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("result_ciphertext", new ColumnSpec("text", true)) .containsEntry("result_key_version", new ColumnSpec("varchar", true)) .containsEntry("version", new ColumnSpec("int8", false)); + assertThat(columnSpecs(connection, "worker_import_job")) + .containsEntry("import_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("source_file_id", new ColumnSpec("uuid", false)) + .containsEntry("status", new ColumnSpec("varchar", false)) + .containsEntry("mapping_json", new ColumnSpec("text", false)) + .containsEntry("source_file_expires_at", new ColumnSpec("timestamptz", false)) + .containsEntry("version", new ColumnSpec("int8", false)); + assertThat(columnSpecs(connection, "worker_import_row")) + .containsEntry("import_row_id", new ColumnSpec("uuid", false)) + .containsEntry("import_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("row_number", new ColumnSpec("int4", false)) + .containsEntry("source_values_json", new ColumnSpec("text", false)) + .containsEntry("validation_errors_json", new ColumnSpec("text", false)) + .containsEntry("worker_id", new ColumnSpec("uuid", true)) + .containsEntry("version", new ColumnSpec("int8", false)); assertThat(constraintNames(connection)) .contains( @@ -380,7 +399,15 @@ private void assertSchemaContract(Connection connection) throws SQLException { "uq_document_ocr_run_idempotency", "fk_document_ocr_run_document_company", "fk_document_ocr_run_file_company", - "fk_document_ocr_run_requester_company" + "fk_document_ocr_run_requester_company", + "pk_worker_import_job", + "uq_worker_import_job_create_key", + "fk_worker_import_job_source_file_company", + "fk_worker_import_job_creator_company", + "pk_worker_import_row", + "uq_worker_import_row_number", + "fk_worker_import_row_job_company", + "fk_worker_import_row_worker_company" ); assertThat(indexNames(connection)) .contains( @@ -417,7 +444,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { "idx_password_reset_token_company_user", "idx_password_reset_token_active", "idx_document_ocr_run_document_created", - "idx_document_ocr_run_company_status" + "idx_document_ocr_run_company_status", + "idx_worker_import_job_company_updated", + "idx_worker_import_row_job_status" ); assertThat(policyNames(connection)) .containsExactlyInAnyOrder( @@ -453,7 +482,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_worker_document_upload_idempotency_tenant_isolation", "pl_user_agreement_consent_tenant_isolation", "pl_password_reset_token_tenant_isolation", - "pl_document_ocr_run_tenant_isolation" + "pl_document_ocr_run_tenant_isolation", + "pl_worker_import_job_tenant_isolation", + "pl_worker_import_row_tenant_isolation" ); assertThat(rlsEnabledTables(connection)).isEmpty(); assertThat(securityDefinerFunctionNames(connection)) diff --git a/src/test/java/com/fowoco/server/workerimport/PostgreSqlWorkerImportRlsTest.java b/src/test/java/com/fowoco/server/workerimport/PostgreSqlWorkerImportRlsTest.java new file mode 100644 index 00000000..d7d93e8c --- /dev/null +++ b/src/test/java/com/fowoco/server/workerimport/PostgreSqlWorkerImportRlsTest.java @@ -0,0 +1,175 @@ +package com.fowoco.server.workerimport; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.common.security.PostgreSqlRlsStateFixture; +import com.fowoco.server.common.security.PostgreSqlRlsTestLock; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable(named = "POSTGRES_TEST_ENABLED", matches = "true") +class PostgreSqlWorkerImportRlsTest { + + private static final UUID COMPANY_A = UUID.fromString("e1000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("e2000000-0000-0000-0000-000000000002"); + private static final UUID USER_A = UUID.fromString("e3000000-0000-0000-0000-000000000001"); + private static final UUID USER_B = UUID.fromString("e4000000-0000-0000-0000-000000000002"); + private static final UUID FILE_A = UUID.fromString("e5000000-0000-0000-0000-000000000001"); + private static final UUID FILE_B = UUID.fromString("e6000000-0000-0000-0000-000000000002"); + private static final UUID IMPORT_A = UUID.fromString("e7000000-0000-0000-0000-000000000001"); + private static final UUID IMPORT_B = UUID.fromString("e8000000-0000-0000-0000-000000000002"); + private static final List TABLES = List.of("worker_import_job", "worker_import_row"); + + @Test + void importTablesFailClosedAndHideOtherCompanyRows() throws Exception { + String url = required("POSTGRES_TEST_URL"); + String migrationUser = required("POSTGRES_TEST_USERNAME"); + String migrationPassword = required("POSTGRES_TEST_PASSWORD"); + String runtimeRole = "worker_import_rls_" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); + String runtimePassword = "Worker-import-" + UUID.randomUUID(); + + try (PostgreSqlRlsTestLock ignored = PostgreSqlRlsTestLock.acquire(url, migrationUser, migrationPassword)) { + Flyway.configure().dataSource(url, migrationUser, migrationPassword) + .locations("classpath:db/migration", "classpath:db/migration-postgresql") + .load().migrate(); + try (Connection admin = DriverManager.getConnection(url, migrationUser, migrationPassword); + PostgreSqlRlsStateFixture state = PostgreSqlRlsStateFixture.capture(admin, TABLES)) { + state.disableRowLevelSecurityForFixtureSetup(); + prepare(admin, runtimeRole, runtimePassword); + state.enableRowLevelSecurity(); + try (Connection runtime = DriverManager.getConnection(url, runtimeRole, runtimePassword)) { + runtime.setAutoCommit(false); + assertThat(count(runtime, "SELECT COUNT(*) FROM worker_import_job")).isZero(); + runtime.rollback(); + + bind(runtime, COMPANY_A); + assertThat(count(runtime, "SELECT COUNT(*) FROM worker_import_job")).isEqualTo(1); + assertThat(count(runtime, "SELECT COUNT(*) FROM worker_import_row")).isEqualTo(1); + runtime.rollback(); + + bind(runtime, COMPANY_A); + assertThatThrownBy(() -> runtime.createStatement().executeUpdate(""" + INSERT INTO worker_import_row ( + import_row_id, import_id, company_id, row_number, + source_values_json, override_values_json, normalized_values_json, + validation_errors_json, status, created_at, updated_at + ) VALUES ( + 'e9000000-0000-0000-0000-000000000009', + 'e8000000-0000-0000-0000-000000000002', + 'e2000000-0000-0000-0000-000000000002', + 3, '{}', '{}', '{}', '[]', 'PENDING', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("row-level security"); + runtime.rollback(); + } finally { + state.disableRowLevelSecurityForFixtureSetup(); + cleanup(admin, runtimeRole); + } + } + } + } + + private void prepare(Connection connection, String role, String password) throws SQLException { + try (Statement statement = connection.createStatement()) { + cleanupRows(statement); + statement.execute("CREATE ROLE " + quoteIdentifier(role) + + " LOGIN PASSWORD " + quoteLiteral(password) + + " NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS"); + statement.execute("GRANT CONNECT ON DATABASE " + quoteIdentifier(connection.getCatalog()) + + " TO " + quoteIdentifier(role)); + statement.execute("GRANT USAGE ON SCHEMA public TO " + quoteIdentifier(role)); + statement.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.worker_import_job," + + " public.worker_import_row TO " + quoteIdentifier(role)); + statement.execute("INSERT INTO company (company_id, name, status) VALUES " + + "('" + COMPANY_A + "', 'Import RLS A', 'ACTIVE')," + + "('" + COMPANY_B + "', 'Import RLS B', 'ACTIVE')"); + statement.execute("INSERT INTO user_account " + + "(user_id, company_id, email, normalized_email, password_hash, role, status) VALUES " + + "('" + USER_A + "','" + COMPANY_A + "','import-a@example.com','import-a@example.com','hash','HR','ACTIVE')," + + "('" + USER_B + "','" + COMPANY_B + "','import-b@example.com','import-b@example.com','hash','HR','ACTIVE')"); + statement.execute("INSERT INTO stored_file " + + "(stored_file_id, company_id, name, mime_type, size, purpose, storage_key, scan_status, verified) VALUES " + + "('" + FILE_A + "','" + COMPANY_A + "','a.csv','text/csv',10,'WORKER_IMPORT_SOURCE','a','NOT_SCANNED',false)," + + "('" + FILE_B + "','" + COMPANY_B + "','b.csv','text/csv',10,'WORKER_IMPORT_SOURCE','b','NOT_SCANNED',false)"); + insertJob(statement, IMPORT_A, COMPANY_A, FILE_A, USER_A, "a"); + insertJob(statement, IMPORT_B, COMPANY_B, FILE_B, USER_B, "b"); + insertRow(statement, "ea000000-0000-0000-0000-000000000001", IMPORT_A, COMPANY_A); + insertRow(statement, "eb000000-0000-0000-0000-000000000002", IMPORT_B, COMPANY_B); + } + } + + private void insertJob(Statement statement, UUID importId, UUID companyId, UUID fileId, UUID userId, String key) + throws SQLException { + statement.execute("INSERT INTO worker_import_job " + + "(import_id, company_id, source_file_id, created_by, status, source_headers_json, mapping_json," + + " create_idempotency_key_hash, create_request_hash, total_rows, source_file_expires_at, created_at, updated_at) VALUES " + + "('" + importId + "','" + companyId + "','" + fileId + "','" + userId + + "','UPLOADED','[\"name\"]','{}',repeat('" + key + "',64),repeat('" + key + + "',64),1,CURRENT_TIMESTAMP + INTERVAL '7 day',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"); + } + + private void insertRow(Statement statement, String rowId, UUID importId, UUID companyId) throws SQLException { + statement.execute("INSERT INTO worker_import_row " + + "(import_row_id, import_id, company_id, row_number, source_values_json, override_values_json," + + " normalized_values_json, validation_errors_json, status, created_at, updated_at) VALUES " + + "('" + rowId + "','" + importId + "','" + companyId + + "',2,'{}','{}','{}','[]','PENDING',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"); + } + + private void bind(Connection connection, UUID companyId) throws SQLException { + try (var statement = connection.prepareStatement("SELECT set_config('app.company_id', ?, true)")) { + statement.setString(1, companyId.toString()); + statement.execute(); + } + } + + private int count(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement(); var result = statement.executeQuery(sql)) { + result.next(); + return result.getInt(1); + } + } + + private void cleanup(Connection connection, String role) throws SQLException { + try (Statement statement = connection.createStatement()) { + cleanupRows(statement); + statement.execute("DROP OWNED BY " + quoteIdentifier(role)); + statement.execute("DROP ROLE IF EXISTS " + quoteIdentifier(role)); + } + } + + private void cleanupRows(Statement statement) throws SQLException { + statement.execute("DELETE FROM worker_import_row WHERE company_id IN ('" + COMPANY_A + "','" + COMPANY_B + "')"); + statement.execute("DELETE FROM worker_import_job WHERE company_id IN ('" + COMPANY_A + "','" + COMPANY_B + "')"); + statement.execute("DELETE FROM stored_file WHERE company_id IN ('" + COMPANY_A + "','" + COMPANY_B + "')"); + statement.execute("DELETE FROM user_account WHERE company_id IN ('" + COMPANY_A + "','" + COMPANY_B + "')"); + statement.execute("DELETE FROM company WHERE company_id IN ('" + COMPANY_A + "','" + COMPANY_B + "')"); + } + + private String required(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " is required"); + } + return value; + } + + private String quoteIdentifier(String value) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + private String quoteLiteral(String value) { + return "'" + value.replace("'", "''") + "'"; + } +} diff --git a/src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java b/src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java new file mode 100644 index 00000000..fc2449f4 --- /dev/null +++ b/src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java @@ -0,0 +1,299 @@ +package com.fowoco.server.workerimport; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.io.ByteArrayOutputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class WorkerImportApiIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("da000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("db000000-0000-0000-0000-000000000002"); + private static final UUID HR_A = UUID.fromString("dc000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("dd000000-0000-0000-0000-000000000002"); + private static final String HR_A_EMAIL = "hr.import.a@example.com"; + private static final String HR_B_EMAIL = "hr.import.b@example.com"; + private static final String PASSWORD = "Test-password-1!"; + private static final String BOUNDARY = "FowocoWorkerImportBoundary"; + + private static final Path FILE_STORAGE_PATH = createFileStoragePath(); + + @DynamicPropertySource + static void fileStorage(DynamicPropertyRegistry registry) { + registry.add("app.file-storage.local-path", FILE_STORAGE_PATH::toString); + } + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeAll + void seedAccounts() { + deleteFixtures(); + insertCompany(COMPANY_A, "Import 사업장 A"); + insertCompany(COMPANY_B, "Import 사업장 B"); + String hash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, hash); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, hash); + } + + @BeforeEach + void resetImports() { + jdbcTemplate.update("DELETE FROM audit_event WHERE target_type = 'WORKER_IMPORT'"); + jdbcTemplate.update("DELETE FROM worker_import_row"); + jdbcTemplate.update("DELETE FROM worker_import_job"); + jdbcTemplate.update("DELETE FROM worker WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM stored_file WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + } + + @AfterAll + void cleanup() { + deleteFixtures(); + } + + @Test + void csvRowsAreReviewedFixedAndCommittedWithoutDuplicateReplay() throws Exception { + String tokenA = accessToken(login(HR_A_EMAIL)); + String csv = "이름,국적,언어,체류만료일\n" + + "응웬반안,VN,vi,2027-01-01\n" + + "쩐티비,VN,vi,잘못된날짜\n"; + HttpResponse created = upload(tokenA, "worker-import-create-0001", "workers.csv", csv); + assertThat(created.statusCode()).as(created.body()).isEqualTo(201); + UUID importId = UUID.fromString(JsonPath.read(created.body(), "$.import_id")); + assertThat(JsonPath.read(created.body(), "$.status")).isEqualTo("UPLOADED"); + + HttpResponse mapped = json( + "PUT", "/api/v1/imports/" + importId + "/mappings", tokenA, null, + """ + {"expected_version":0,"mappings":{ + "이름":"display_name","국적":"nationality_code", + "언어":"preferred_language","체류만료일":"stay_expiry_date" + }} + """ + ); + assertThat(mapped.statusCode()).as(mapped.body()).isEqualTo(200); + assertThat(JsonPath.read(mapped.body(), "$.status")).isEqualTo("MAPPED"); + + HttpResponse validated = json( + "POST", "/api/v1/imports/" + importId + "/validate", tokenA, null, + "{\"expected_version\":1}" + ); + assertThat(validated.statusCode()).as(validated.body()).isEqualTo(200); + assertThat(JsonPath.read(validated.body(), "$.status")).isEqualTo("REVIEW_REQUIRED"); + assertThat(JsonPath.read(validated.body(), "$.valid_rows")).isEqualTo(1); + assertThat(JsonPath.read(validated.body(), "$.invalid_rows")).isEqualTo(1); + + HttpResponse patched = json( + "PATCH", "/api/v1/imports/" + importId + "/rows", tokenA, null, + """ + {"expected_version":2,"rows":[{ + "row_number":3,"excluded":false, + "values":{"stay_expiry_date":"2027-02-01"} + }]} + """ + ); + assertThat(patched.statusCode()).as(patched.body()).isEqualTo(200); + + HttpResponse retried = json( + "POST", "/api/v1/imports/" + importId + "/retry", tokenA, null, + "{\"expected_version\":3}" + ); + assertThat(retried.statusCode()).as(retried.body()).isEqualTo(200); + assertThat(JsonPath.read(retried.body(), "$.status")).isEqualTo("READY"); + assertThat(JsonPath.read(retried.body(), "$.valid_rows")).isEqualTo(2); + + HttpResponse firstCommit = json( + "POST", "/api/v1/imports/" + importId + "/commit", tokenA, "worker-import-commit-0001", + "{\"expected_version\":4,\"selected_row_numbers\":[2]}" + ); + assertThat(firstCommit.statusCode()).as(firstCommit.body()).isEqualTo(200); + assertThat(JsonPath.read(firstCommit.body(), "$.committed_rows")).isEqualTo(1); + assertThat(JsonPath.read(firstCommit.body(), "$.status")).isEqualTo("READY"); + + HttpResponse replay = json( + "POST", "/api/v1/imports/" + importId + "/commit", tokenA, "worker-import-commit-0001", + "{\"expected_version\":4,\"selected_row_numbers\":[2]}" + ); + assertThat(replay.statusCode()).as(replay.body()).isEqualTo(200); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker WHERE company_id = ?", Integer.class, COMPANY_A + )).isEqualTo(1); + + HttpResponse finalCommit = json( + "POST", "/api/v1/imports/" + importId + "/commit", tokenA, "worker-import-commit-0002", + "{\"expected_version\":5,\"selected_row_numbers\":[3]}" + ); + assertThat(finalCommit.statusCode()).as(finalCommit.body()).isEqualTo(200); + assertThat(JsonPath.read(finalCommit.body(), "$.status")).isEqualTo("COMMITTED"); + assertThat(JsonPath.read(finalCommit.body(), "$.committed_rows")).isEqualTo(2); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker WHERE company_id = ?", Integer.class, COMPANY_A + )).isEqualTo(2); + + String tokenB = accessToken(login(HR_B_EMAIL)); + HttpResponse otherTenant = get("/api/v1/imports/" + importId, tokenB); + assertThat(otherTenant.statusCode()).isEqualTo(404); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE target_id = ? AND action = 'WORKER_IMPORT_COMMITTED'", + Integer.class, + importId + )).isEqualTo(2); + } + + @Test + void uploadRejectsSensitiveColumnBeforePersistingFile() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + HttpResponse response = upload( + token, + "worker-import-create-0002", + "workers.csv", + "이름,외국인등록번호\n응웬반안,000000-0000000\n" + ); + + assertThat(response.statusCode()).as(response.body()).isEqualTo(422); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("IMPORT_SENSITIVE_COLUMN_NOT_ALLOWED"); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM worker_import_job", Integer.class)).isZero(); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM stored_file WHERE company_id = ?", Integer.class, COMPANY_A + )).isZero(); + } + + private HttpResponse upload(String token, String key, String fileName, String csv) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(("--" + BOUNDARY + "\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(("Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n") + .getBytes(StandardCharsets.UTF_8)); + out.write("Content-Type: text/csv\r\n\r\n".getBytes(StandardCharsets.UTF_8)); + out.write(csv.getBytes(StandardCharsets.UTF_8)); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + out.write(("--" + BOUNDARY + "--\r\n").getBytes(StandardCharsets.UTF_8)); + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/imports")) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .header("Idempotency-Key", key) + .header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + BOUNDARY) + .POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse json( + String method, + String path, + String token, + String idempotencyKey, + String body + ) throws Exception { + HttpRequest.Builder request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .header(HttpHeaders.CONTENT_TYPE, MediaTypeValue.JSON) + .method(method, HttpRequest.BodyPublishers.ofString(body)); + if (idempotencyKey != null) { + request.header("Idempotency-Key", idempotencyKey); + } + return httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse get(String path, String token) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .GET().build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private HttpResponse login(String email) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, MediaTypeValue.JSON) + .POST(HttpRequest.BodyPublishers.ofString( + "{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}" + )).build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private String accessToken(HttpResponse response) { + assertThat(response.statusCode()).as(response.body()).isEqualTo(200); + return JsonPath.read(response.body(), "$.access_token"); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } + + private void insertCompany(UUID id, String name) { + jdbcTemplate.update( + "INSERT INTO company (company_id, name, status) VALUES (?, ?, 'ACTIVE')", + id, + name + ); + } + + private void insertUser(UUID id, UUID companyId, String email, String passwordHash) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, role, status + ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE') + """, + id, companyId, email, email, passwordHash + ); + } + + private void deleteFixtures() { + jdbcTemplate.update("DELETE FROM audit_event WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM worker_import_row WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM worker_import_job WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM worker WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM stored_file WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM refresh_token WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM user_account WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM company WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + } + + private static final class MediaTypeValue { + private static final String JSON = "application/json"; + } + + private static Path createFileStoragePath() { + try { + return Files.createTempDirectory("fowoco-worker-import-test-"); + } catch (java.io.IOException exception) { + throw new IllegalStateException(exception); + } + } +} diff --git a/src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java b/src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java new file mode 100644 index 00000000..d226cfe9 --- /dev/null +++ b/src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java @@ -0,0 +1,73 @@ +package com.fowoco.server.workerimport.infrastructure.parsing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.workerimport.application.error.WorkerImportErrorCode; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; + +class DefaultWorkerImportFileParserTest { + + private final DefaultWorkerImportFileParser parser = new DefaultWorkerImportFileParser(); + + @Test + void parsesQuotedUtf8CsvWithoutLosingCommas() { + var result = parser.parse( + "workers.csv", + "이름,국적,메모\n\"응웬, 반안\",VN,정상\n".getBytes(StandardCharsets.UTF_8) + ); + + assertThat(result.headers()).containsExactly("이름", "국적", "메모"); + assertThat(result.rows()).hasSize(1); + assertThat(result.rows().get(0).get("이름")).isEqualTo("응웬, 반안"); + } + + @Test + void rejectsSensitiveColumnsAndFormulaInjection() { + assertThatThrownBy(() -> parser.parse( + "workers.csv", + "이름,여권번호\n응웬반안,M1234\n".getBytes(StandardCharsets.UTF_8) + )).isInstanceOfSatisfying(ApiException.class, exception -> + assertThat(exception.errorCode()).isEqualTo(WorkerImportErrorCode.IMPORT_SENSITIVE_COLUMN_NOT_ALLOWED) + ); + + assertThatThrownBy(() -> parser.parse( + "workers.csv", + "이름,국적\n=HYPERLINK(\"x\"),VN\n".getBytes(StandardCharsets.UTF_8) + )).isInstanceOfSatisfying(ApiException.class, exception -> + assertThat(exception.errorCode()).isEqualTo(WorkerImportErrorCode.IMPORT_FORMULA_NOT_ALLOWED) + ); + } + + @Test + void parsesXlsxButRejectsFormulaCells() throws Exception { + byte[] normal; + try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + var sheet = workbook.createSheet("workers"); + sheet.createRow(0).createCell(0).setCellValue("이름"); + sheet.getRow(0).createCell(1).setCellValue("국적"); + sheet.createRow(1).createCell(0).setCellValue("응웬반안"); + sheet.getRow(1).createCell(1).setCellValue("VN"); + workbook.write(output); + normal = output.toByteArray(); + } + assertThat(parser.parse("workers.xlsx", normal).rows()).hasSize(1); + + byte[] formula; + try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + var sheet = workbook.createSheet("workers"); + sheet.createRow(0).createCell(0).setCellValue("이름"); + sheet.createRow(1).createCell(0).setCellFormula("1+1"); + workbook.write(output); + formula = output.toByteArray(); + } + assertThatThrownBy(() -> parser.parse("workers.xlsx", formula)) + .isInstanceOfSatisfying(ApiException.class, exception -> + assertThat(exception.errorCode()).isEqualTo(WorkerImportErrorCode.IMPORT_FORMULA_NOT_ALLOWED) + ); + } +} From 5f5fbbf231b3e15297c1de67294882c207858789 Mon Sep 17 00:00:00 2001 From: hywznn Date: Fri, 7 Aug 2026 18:07:44 +0900 Subject: [PATCH 4/9] =?UTF-8?q?docs(import):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=EB=B3=84=20API=EC=99=80=20=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EA=B7=9C=EC=B9=99=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + README.md | 4 +- docs/project-structure.md | 1 + docs/worker-import.md | 141 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 docs/worker-import.md diff --git a/.env.example b/.env.example index dfe2cc31..34128d3b 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,7 @@ DOCUMENT_OCR_ENABLED=false # 생성 예시: openssl rand -base64 32 # OCR_RESULT_ENCRYPTION_KEY_BASE64= OCR_RESULT_KEY_VERSION=local-v1 +WORKER_IMPORT_SOURCE_RETENTION=7d # Client용 AiRun 상태 SSE입니다. 실행 기준은 SSE가 아니라 DB와 GET /api/v1/ai-runs/{id}입니다. AI_RUN_SSE_TIMEOUT=5m diff --git a/README.md b/README.md index 879f70f0..a5c17ba5 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 해야 할 일을 업무카 | 패키지·모듈 경계 | [프로젝트 구조](docs/project-structure.md) | 코드를 어느 패키지에 구현해야 하는지 설명 | | 중요한 설계 결정 | [ADR 목록](docs/adr/README.md) | 저장소 경계, API·보안, Task·AiRun, RLS 결정 원본 | | Server ↔ AI 계약 | [AI Runtime 계약](docs/ai-runtime-contract.md) | Server가 AI에 보내고 받을 수 있는 값과 검증 기준 | +| 근로자 명단 가져오기 | [Worker Import 가이드](docs/worker-import.md) | CSV/XLSX 업로드부터 검증·수정·등록까지의 API 순서 | | Agent DB 정보 보충 | [Slot 조회·재호출](docs/ai-slot-resolution.md) | canonical key allow-list, tenant 조회와 ANALYZE 재호출 기준 | | 이벤트 유실·재처리 | [Outbox 운영 가이드](docs/reliability/transactional-outbox.md) | 이벤트 발행, lease, 재시도와 장애 복구 기준 | | 구현 계획·업무 상태 | [Server Roadmap](https://github.com/orgs/fowoco/projects/3) · [Issues](https://github.com/fowoco/server/issues) | 실제 담당자, 우선순위와 진행 상태 | @@ -72,6 +73,7 @@ Provider SDK, Prompt와 모델 라우팅은 Server에 구현하지 않습니다. | --- | --- | | Auth·Company | 회원가입, 로그인, JWT Access Token, Refresh Token 회전·로그아웃 | | Worker·Document | 근로자 기본정보와 서류 메타데이터 등록·조회·수정 | +| Worker Import | CSV/XLSX 열 연결, 행 검증·제외·수정과 선택 등록 | | Task·Workflow | Knowledge projection 조회, 업무카드·체크리스트·상태 전이 | | Approval·Audit | 승인·반려·외부 제출·증빙·완료와 감사 이벤트 | | AI Integration | Provider-neutral 계약, 개인정보 차단과 응답·version 검증 | @@ -159,7 +161,7 @@ Server는 하나의 Spring Boot 애플리케이션과 PostgreSQL로 배포하는 src/main/java/com/fowoco/server/ ├── common ├── auth / company -├── worker +├── worker / workerimport ├── workflow / task ├── approval / audit ├── workerlink / file diff --git a/docs/project-structure.md b/docs/project-structure.md index 977991eb..303168a1 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -28,6 +28,7 @@ server/ │ │ ├── auth/ │ │ ├── company/ │ │ ├── worker/ + │ │ ├── workerimport/ # CSV/XLSX 명단 검증·검토·등록 │ │ ├── document/ │ │ ├── file/ │ │ ├── workflow/ diff --git a/docs/worker-import.md b/docs/worker-import.md new file mode 100644 index 00000000..3f2a7b59 --- /dev/null +++ b/docs/worker-import.md @@ -0,0 +1,141 @@ +# Worker Import 구현·연동 가이드 + +## 한 줄 설명 + +HR이 CSV/XLSX 근로자 명단을 올리면 서버가 즉시 등록하지 않고, 열 연결과 행 검증을 거친 뒤 HR이 선택한 정상 행만 `worker`로 등록합니다. + +```text +UPLOADED → MAPPED → REVIEW_REQUIRED 또는 READY → COMMITTED +``` + +오류 행을 수정하거나 제외하면 `MAPPED`로 돌아가며, `retry`로 다시 검증합니다. 일부 정상 행만 먼저 등록한 경우 남은 오류·정상 행을 계속 처리할 수 있습니다. + +## 화면 단계와 API + +| 화면 단계 | API | 서버 동작 | +| --- | --- | --- | +| 파일 업로드 | `POST /api/v1/imports` | CSV/XLSX 구조·크기·수식·차단 열을 검사하고 작업 생성 | +| 진행·검토 조회 | `GET /api/v1/imports/{importId}` | 현재 상태, 건수와 행별 오류 조회 | +| 열 연결 | `PUT /api/v1/imports/{importId}/mappings` | 업로드 열을 Worker 필드에 연결 | +| 자료 검증 | `POST /api/v1/imports/{importId}/validate` | 필수값·날짜·국적 코드·중복 후보 검사 | +| 오류 수정·제외 | `PATCH /api/v1/imports/{importId}/rows` | 원본과 별도로 수정값 저장 또는 행 제외 | +| 등록 확정 | `POST /api/v1/imports/{importId}/commit` | 선택한 `VALID` 행만 근로자로 등록 | +| 실패 행 재검증 | `POST /api/v1/imports/{importId}/retry` | 수정 후 아직 등록하지 않은 행 재검증 | + +모든 API는 `ADMIN` 또는 `HR`만 사용할 수 있습니다. `company_id`는 요청에서 받지 않고 JWT의 `ActorContext`로 결정하며, 다른 사업장 작업은 `404`로 숨깁니다. + +## 지원 필드 + +열 연결의 값은 아래 canonical key만 허용합니다. + +- `display_name` — 필수 +- `nationality_code` — ISO alpha-2, 예: `VN` +- `preferred_language` +- `visa_type` +- `stay_expiry_date` +- `contract_start_date` +- `contract_end_date` +- `employment_permit_end_date` +- `employment_activity_end_date` + +날짜는 `YYYY-MM-DD` 형식입니다. 새 근로자의 `work_status`는 기존 Worker 등록 규칙과 동일하게 `ACTIVE`로 시작합니다. + +## 요청 예시 + +### 1. 업로드 + +```http +POST /api/v1/imports +Authorization: Bearer +Idempotency-Key: import-20260807-001 +Content-Type: multipart/form-data + +file=@workers.csv +``` + +### 2. 열 연결 + +```json +{ + "expected_version": 0, + "mappings": { + "이름": "display_name", + "국적": "nationality_code", + "언어": "preferred_language", + "체류만료일": "stay_expiry_date" + } +} +``` + +### 3. 오류 행 수정 + +```json +{ + "expected_version": 2, + "rows": [ + { + "row_number": 3, + "excluded": false, + "values": { + "stay_expiry_date": "2027-02-01" + } + } + ] +} +``` + +### 4. 정상 행 등록 + +```http +POST /api/v1/imports/{importId}/commit +Idempotency-Key: commit-20260807-001 +``` + +```json +{ + "expected_version": 4, + "selected_row_numbers": [2, 3] +} +``` + +`selected_row_numbers`를 생략하면 현재 `VALID`인 모든 행을 등록합니다. 같은 `Idempotency-Key`와 같은 선택 행을 다시 보내도 이미 등록된 근로자를 중복 생성하지 않습니다. + +## 파일·개인정보 규칙 + +- 최대 5MB, 1,000개 데이터 행, 50개 열까지만 허용합니다. +- UTF-8 CSV와 매크로가 없는 XLSX만 지원합니다. +- CSV 수식 시작 문자(`=`, `+`, `-`, `@`)와 XLSX Formula Cell을 거부합니다. +- 여권번호·외국인등록번호·계좌번호와 MVP에서 수집하지 않는 연락처·이메일·주소·생년월일·법정실명 열은 업로드 단계에서 거부합니다. +- 원본 행 전체를 일반 로그나 감사로그에 남기지 않습니다. +- 원본 파일 만료 예정 시각은 `source_file_expires_at`으로 계산하며 기본값은 7일입니다. + +`WORKER_IMPORT_SOURCE_RETENTION`은 보존기간 계산값입니다. 실제 Object Storage 삭제 배치는 File Storage 운영 정책과 함께 연결해야 하며, 만료 시각이 지나도 DB 이력을 임의 삭제하지 않습니다. + +## 오류를 읽는 방법 + +행 오류는 `field`, `code`, `message`로 반환됩니다. + +```json +{ + "field": "stay_expiry_date", + "code": "INVALID_DATE", + "message": "날짜는 YYYY-MM-DD 형식이어야 합니다." +} +``` + +대표 코드는 `REQUIRED`, `INVALID_FORMAT`, `INVALID_DATE`, `DATE_ORDER`, `DUPLICATE_CANDIDATE`입니다. 서버는 중복 후보를 자동 병합하지 않으며 HR이 값을 수정하거나 해당 행을 제외해야 합니다. + +## 개발자가 확인할 테스트 + +```bash +./gradlew test --tests '*DefaultWorkerImportFileParserTest' \ + --tests '*WorkerImportApiIntegrationTest' +``` + +PostgreSQL 환경변수가 준비된 경우 Migration과 RLS도 확인합니다. + +```bash +POSTGRES_TEST_ENABLED=true ./gradlew test \ + --tests '*PostgreSqlMigrationTests' \ + --tests '*PostgreSqlWorkerImportRlsTest' +``` From 80bc415c64253530321dbaae8834a3913eb506d9 Mon Sep 17 00:00:00 2001 From: hywznn Date: Fri, 7 Aug 2026 18:14:39 +0900 Subject: [PATCH 5/9] =?UTF-8?q?docs(import):=20OpenAPI=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=9D=91=EB=8B=B5=EA=B3=BC=20snake=5Fcase=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/WorkerImportCommitRequest.java | 3 + .../api/WorkerImportController.java | 22 ++++- .../api/WorkerImportMappingRequest.java | 3 + .../api/WorkerImportResponse.java | 3 + .../api/WorkerImportRowPatchRequest.java | 3 + .../api/WorkerImportRowResponse.java | 3 + .../api/WorkerImportRowsRequest.java | 3 + .../api/WorkerImportValidateRequest.java | 3 + .../WorkerImportOpenApiContractTest.java | 94 +++++++++++++++++++ 9 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/fowoco/server/workerimport/WorkerImportOpenApiContractTest.java diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportCommitRequest.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportCommitRequest.java index 6c516820..c4de8ea6 100644 --- a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportCommitRequest.java +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportCommitRequest.java @@ -1,9 +1,12 @@ package com.fowoco.server.workerimport.api; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import java.util.Set; +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record WorkerImportCommitRequest( @NotNull Long expectedVersion, Set<@Min(2) Integer> selectedRowNumbers diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportController.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportController.java index 9c766a48..5cceb51c 100644 --- a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportController.java +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportController.java @@ -8,7 +8,10 @@ import com.fowoco.server.workerimport.application.WorkerImportService; import com.fowoco.server.workerimport.application.error.WorkerImportErrorCode; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -40,6 +43,14 @@ @RestController @RequestMapping("/api/v1/imports") @SecurityRequirement(name = "bearerAuth") +@ApiResponses({ + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity") +}) @Validated public class WorkerImportController { @@ -52,6 +63,15 @@ public WorkerImportController(WorkerImportService service, ActorContextProvider } @Operation(summary = "근로자 명단 가져오기 생성", description = "CSV/XLSX를 파싱해 검토 작업을 만듭니다. 아직 근로자는 등록하지 않습니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "가져오기 작업 생성", + content = @Content(schema = @Schema(implementation = WorkerImportResponse.class)) + ), + @ApiResponse(responseCode = "413", description = "파일 크기 제한 초과"), + @ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType") + }) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @PreAuthorize("hasAnyRole('ADMIN', 'HR')") public ResponseEntity create( diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportMappingRequest.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportMappingRequest.java index 54863eda..1ce6c1b1 100644 --- a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportMappingRequest.java +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportMappingRequest.java @@ -1,9 +1,12 @@ package com.fowoco.server.workerimport.api; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.util.Map; +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record WorkerImportMappingRequest( @NotNull Long expectedVersion, @NotEmpty Map mappings diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportResponse.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportResponse.java index 4f3784d2..dd757e86 100644 --- a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportResponse.java +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportResponse.java @@ -1,5 +1,7 @@ package com.fowoco.server.workerimport.api; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.fowoco.server.workerimport.application.WorkerImportView; import com.fowoco.server.workerimport.domain.WorkerImportStatus; import java.time.Instant; @@ -8,6 +10,7 @@ import java.util.Map; import java.util.UUID; +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record WorkerImportResponse( UUID importId, UUID sourceFileId, diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowPatchRequest.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowPatchRequest.java index ccf94df9..518ff4bd 100644 --- a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowPatchRequest.java +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowPatchRequest.java @@ -1,9 +1,12 @@ package com.fowoco.server.workerimport.api; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import java.util.Map; +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record WorkerImportRowPatchRequest( @Min(2) int rowNumber, Boolean excluded, diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowResponse.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowResponse.java index 3487e655..bed41960 100644 --- a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowResponse.java +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowResponse.java @@ -1,5 +1,7 @@ package com.fowoco.server.workerimport.api; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.fowoco.server.workerimport.application.ImportValidationError; import com.fowoco.server.workerimport.application.WorkerImportRowRecord; import com.fowoco.server.workerimport.domain.WorkerImportRowStatus; @@ -7,6 +9,7 @@ import java.util.Map; import java.util.UUID; +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record WorkerImportRowResponse( int rowNumber, Map sourceValues, diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowsRequest.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowsRequest.java index 5293198d..a68a425e 100644 --- a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowsRequest.java +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportRowsRequest.java @@ -1,10 +1,13 @@ package com.fowoco.server.workerimport.api; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.util.List; +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record WorkerImportRowsRequest( @NotNull Long expectedVersion, @NotEmpty List<@Valid WorkerImportRowPatchRequest> rows diff --git a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportValidateRequest.java b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportValidateRequest.java index cb309444..ed39f951 100644 --- a/src/main/java/com/fowoco/server/workerimport/api/WorkerImportValidateRequest.java +++ b/src/main/java/com/fowoco/server/workerimport/api/WorkerImportValidateRequest.java @@ -1,6 +1,9 @@ package com.fowoco.server.workerimport.api; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; import jakarta.validation.constraints.NotNull; +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record WorkerImportValidateRequest(@NotNull Long expectedVersion) { } diff --git a/src/test/java/com/fowoco/server/workerimport/WorkerImportOpenApiContractTest.java b/src/test/java/com/fowoco/server/workerimport/WorkerImportOpenApiContractTest.java new file mode 100644 index 00000000..5a17c4a5 --- /dev/null +++ b/src/test/java/com/fowoco/server/workerimport/WorkerImportOpenApiContractTest.java @@ -0,0 +1,94 @@ +package com.fowoco.server.workerimport; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class WorkerImportOpenApiContractTest { + + @LocalServerPort + private int port; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private JsonNode openApi; + + @BeforeAll + void loadOpenApi() throws Exception { + HttpResponse response = HttpClient.newHttpClient().send( + HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/v3/api-docs")).GET().build(), + HttpResponse.BodyHandlers.ofString() + ); + assertThat(response.statusCode()).isEqualTo(200); + openApi = objectMapper.readTree(response.body()); + } + + @Test + void documentsAllSevenCanonicalImportOperations() { + List operations = List.of( + "/paths/~1api~1v1~1imports/post", + "/paths/~1api~1v1~1imports~1{importId}/get", + "/paths/~1api~1v1~1imports~1{importId}~1mappings/put", + "/paths/~1api~1v1~1imports~1{importId}~1validate/post", + "/paths/~1api~1v1~1imports~1{importId}~1rows/patch", + "/paths/~1api~1v1~1imports~1{importId}~1commit/post", + "/paths/~1api~1v1~1imports~1{importId}~1retry/post" + ); + operations.forEach(pointer -> { + JsonNode operation = openApi.at(pointer); + assertThat(operation.isMissingNode()).as(pointer).isFalse(); + assertThat(operation.path("security").toString()).contains("bearerAuth"); + assertThat(operation.path("responses").has("401")).isTrue(); + assertThat(operation.path("responses").has("403")).isTrue(); + }); + } + + @Test + void uploadAndCommitExposeMultipartAndIdempotencyContracts() { + JsonNode upload = openApi.at("/paths/~1api~1v1~1imports/post"); + JsonNode commit = openApi.at("/paths/~1api~1v1~1imports~1{importId}~1commit/post"); + + assertThat(upload.at("/requestBody/content/multipart~1form-data").isMissingNode()).isFalse(); + assertThat(hasRequiredHeader(upload, "Idempotency-Key")).isTrue(); + assertThat(upload.path("responses").has("201")).isTrue(); + assertThat(upload.path("responses").has("413")).isTrue(); + assertThat(upload.path("responses").has("415")).isTrue(); + assertThat(hasRequiredHeader(commit, "Idempotency-Key")).isTrue(); + } + + @Test + void requestAndResponseSchemasUseSnakeCase() { + JsonNode mapping = openApi.at("/components/schemas/WorkerImportMappingRequest/properties"); + JsonNode response = openApi.at("/components/schemas/WorkerImportResponse/properties"); + + assertThat(mapping.has("expected_version")).isTrue(); + assertThat(response.has("import_id")).isTrue(); + assertThat(response.has("source_file_expires_at")).isTrue(); + assertThat(response.has("committed_rows")).isTrue(); + } + + private boolean hasRequiredHeader(JsonNode operation, String name) { + for (JsonNode parameter : operation.path("parameters")) { + if (name.equals(parameter.path("name").asText()) + && "header".equals(parameter.path("in").asText()) + && parameter.path("required").asBoolean()) { + return true; + } + } + return false; + } +} From 6e6e56b2bb497c1738e3613f99f0ffebe4a309b2 Mon Sep 17 00:00:00 2001 From: hywznn Date: Fri, 7 Aug 2026 18:16:47 +0900 Subject: [PATCH 6/9] =?UTF-8?q?test(import):=20=EB=B9=88=20=EB=AA=85?= =?UTF-8?q?=EB=8B=A8=EA=B3=BC=20=EC=B5=9C=EB=8C=80=20=ED=96=89=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkerImportApiIntegrationTest.java | 33 +++++++++++++++++++ .../DefaultWorkerImportFileParserTest.java | 11 +++++++ 2 files changed, 44 insertions(+) diff --git a/src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java b/src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java index fc2449f4..ca3aeac3 100644 --- a/src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java +++ b/src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java @@ -191,6 +191,39 @@ void uploadRejectsSensitiveColumnBeforePersistingFile() throws Exception { )).isZero(); } + @Test + void uploadRejectsEmptyFileAndRowsBeyondTheLimit() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + + HttpResponse empty = upload( + token, + "worker-import-empty-0001", + "empty.csv", + "이름,국적\n" + ); + assertThat(empty.statusCode()).as(empty.body()).isEqualTo(422); + assertThat(JsonPath.read(empty.body(), "$.code")).isEqualTo("IMPORT_FILE_EMPTY"); + + StringBuilder tooManyRows = new StringBuilder("이름,국적\n"); + for (int row = 1; row <= 1_001; row++) { + tooManyRows.append("근로자").append(row).append(",VN\n"); + } + HttpResponse overLimit = upload( + token, + "worker-import-limit-0001", + "too-many-workers.csv", + tooManyRows.toString() + ); + assertThat(overLimit.statusCode()).as(overLimit.body()).isEqualTo(422); + assertThat(JsonPath.read(overLimit.body(), "$.code")) + .isEqualTo("IMPORT_FILE_LIMIT_EXCEEDED"); + + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM worker_import_job", Integer.class)).isZero(); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM stored_file WHERE company_id = ?", Integer.class, COMPANY_A + )).isZero(); + } + private HttpResponse upload(String token, String key, String fileName, String csv) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(("--" + BOUNDARY + "\r\n").getBytes(StandardCharsets.UTF_8)); diff --git a/src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java b/src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java index d226cfe9..6c7116d6 100644 --- a/src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java +++ b/src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java @@ -70,4 +70,15 @@ void parsesXlsxButRejectsFormulaCells() throws Exception { assertThat(exception.errorCode()).isEqualTo(WorkerImportErrorCode.IMPORT_FORMULA_NOT_ALLOWED) ); } + + @Test + void acceptsExactlyOneThousandDataRows() { + StringBuilder csv = new StringBuilder("이름,국적\n"); + for (int row = 1; row <= 1_000; row++) { + csv.append("근로자").append(row).append(",VN\n"); + } + + assertThat(parser.parse("workers.csv", csv.toString().getBytes(StandardCharsets.UTF_8)).rows()) + .hasSize(1_000); + } } From e6bebc5510166fafce280b6339f6056cdd84d3ea Mon Sep 17 00:00:00 2001 From: hywznn Date: Sat, 8 Aug 2026 02:41:25 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix(import):=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EB=A9=B1=EB=93=B1=EC=84=B1=20=EC=9D=B4=EB=A0=A5=EC=9D=84=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EB=B3=84=EB=A1=9C=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/WorkerImportCommitRecord.java | 14 +++++ .../application/WorkerImportJobRecord.java | 2 - .../application/WorkerImportService.java | 32 +++++----- .../port/WorkerImportRepository.java | 7 ++- .../JdbcWorkerImportRepository.java | 59 +++++++++++++++---- .../V33__prepare_worker_import_rls.sql | 11 ++++ .../migration/V32__create_worker_import.sql | 27 ++++++--- .../server/PostgreSqlMigrationTests.java | 17 +++++- .../PostgreSqlWorkerImportRlsTest.java | 21 ++++++- .../WorkerImportApiIntegrationTest.java | 38 +++++++++--- 10 files changed, 176 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/fowoco/server/workerimport/application/WorkerImportCommitRecord.java diff --git a/src/main/java/com/fowoco/server/workerimport/application/WorkerImportCommitRecord.java b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportCommitRecord.java new file mode 100644 index 00000000..9edaaeef --- /dev/null +++ b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportCommitRecord.java @@ -0,0 +1,14 @@ +package com.fowoco.server.workerimport.application; + +import java.time.Instant; +import java.util.UUID; + +public record WorkerImportCommitRecord( + UUID companyId, + UUID importId, + String idempotencyKeyHash, + String requestHash, + WorkerImportView responseSnapshot, + Instant createdAt +) { +} diff --git a/src/main/java/com/fowoco/server/workerimport/application/WorkerImportJobRecord.java b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportJobRecord.java index b57b2a34..c5dc9ba2 100644 --- a/src/main/java/com/fowoco/server/workerimport/application/WorkerImportJobRecord.java +++ b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportJobRecord.java @@ -17,8 +17,6 @@ public record WorkerImportJobRecord( Map mappings, String createIdempotencyKeyHash, String createRequestHash, - String lastCommitIdempotencyKeyHash, - String lastCommitRequestHash, int totalRows, int validRows, int invalidRows, diff --git a/src/main/java/com/fowoco/server/workerimport/application/WorkerImportService.java b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportService.java index 7413cc78..3590a87e 100644 --- a/src/main/java/com/fowoco/server/workerimport/application/WorkerImportService.java +++ b/src/main/java/com/fowoco/server/workerimport/application/WorkerImportService.java @@ -152,8 +152,6 @@ public WorkerImportView create( Map.of(), keyHash, requestHash, - null, - null, parsed.rows().size(), 0, 0, @@ -212,7 +210,7 @@ public WorkerImportView saveMappings( } } Counts counts = counts(repository.findAllRows(actor.companyId(), importId)); - updateJob(job, WorkerImportStatus.MAPPED, mappings, counts, null, null, now); + updateJob(job, WorkerImportStatus.MAPPED, mappings, counts, now); appendAudit(actor, AuditAction.WORKER_IMPORT_MAPPING_UPDATED, importId, "가져오기 열 연결 수정", metadata, now); return view(requireJob(actor.companyId(), importId), 0, 100); } @@ -255,7 +253,7 @@ public WorkerImportView patchRows( ); } Counts counts = counts(repository.findAllRows(actor.companyId(), importId)); - updateJob(job, WorkerImportStatus.MAPPED, job.mappings(), counts, null, null, now); + updateJob(job, WorkerImportStatus.MAPPED, job.mappings(), counts, now); appendAudit(actor, AuditAction.WORKER_IMPORT_ROWS_UPDATED, importId, "가져오기 행 수정", metadata, now); return view(requireJob(actor.companyId(), importId), 0, 100); } @@ -299,7 +297,7 @@ public WorkerImportView validate( WorkerImportStatus status = counts.invalid() > 0 || counts.valid() == 0 ? WorkerImportStatus.REVIEW_REQUIRED : WorkerImportStatus.READY; - updateJob(job, status, job.mappings(), counts, null, null, now); + updateJob(job, status, job.mappings(), counts, now); appendAudit( actor, retry ? AuditAction.WORKER_IMPORT_RETRIED : AuditAction.WORKER_IMPORT_VALIDATED, @@ -322,16 +320,18 @@ public WorkerImportView commit( ) { bindTenant(actor); WorkerImportJobRecord job = requireJob(actor.companyId(), importId); + Set requestedRows = selectedRows == null ? Set.of() : Set.copyOf(selectedRows); String keyHash = sha256(normalizeIdempotencyKey(idempotencyKey) .getBytes(java.nio.charset.StandardCharsets.UTF_8)); - String requestHash = sha256(selectedRows.stream().sorted().map(String::valueOf) + String requestHash = sha256(requestedRows.stream().sorted().map(String::valueOf) .collect(Collectors.joining(",")) .getBytes(java.nio.charset.StandardCharsets.UTF_8)); - if (keyHash.equals(job.lastCommitIdempotencyKeyHash())) { - if (!requestHash.equals(job.lastCommitRequestHash())) { + var previousCommit = repository.findCommitByKey(actor.companyId(), importId, keyHash); + if (previousCommit.isPresent()) { + if (!requestHash.equals(previousCommit.get().requestHash())) { throw new ApiException(WorkerImportErrorCode.IMPORT_IDEMPOTENCY_CONFLICT); } - return view(job, 0, 100); + return previousCommit.get().responseSnapshot(); } if (job.version() != expectedVersion) { throw new ApiException(WorkerImportErrorCode.IMPORT_VERSION_CONFLICT); @@ -342,7 +342,7 @@ public WorkerImportView commit( List rows = repository.findAllRows(actor.companyId(), importId); List candidates = rows.stream() .filter(row -> row.status() == WorkerImportRowStatus.VALID) - .filter(row -> selectedRows.isEmpty() || selectedRows.contains(row.rowNumber())) + .filter(row -> requestedRows.isEmpty() || requestedRows.contains(row.rowNumber())) .toList(); if (candidates.isEmpty()) { throw new ApiException(WorkerImportErrorCode.IMPORT_NO_VALID_ROWS); @@ -375,9 +375,13 @@ public WorkerImportView commit( WorkerImportStatus status = counts.invalid() > 0 ? WorkerImportStatus.REVIEW_REQUIRED : counts.valid() > 0 ? WorkerImportStatus.READY : WorkerImportStatus.COMMITTED; - updateJob(job, status, job.mappings(), counts, keyHash, requestHash, now); + updateJob(job, status, job.mappings(), counts, now); + WorkerImportView result = view(requireJob(actor.companyId(), importId), 0, 100); + repository.insertCommit(new WorkerImportCommitRecord( + actor.companyId(), importId, keyHash, requestHash, result, now + )); appendAudit(actor, AuditAction.WORKER_IMPORT_COMMITTED, importId, "정상 행 근로자 등록 확정", metadata, now); - return view(requireJob(actor.companyId(), importId), 0, 100); + return result; } private Map validateMappings( @@ -546,14 +550,12 @@ private void updateJob( WorkerImportStatus status, Map mappings, Counts counts, - String commitKeyHash, - String commitRequestHash, Instant now ) { boolean updated = repository.updateJob( job.companyId(), job.importId(), job.version(), status, mappings, counts.valid(), counts.invalid(), counts.excluded(), counts.committed(), - commitKeyHash, commitRequestHash, now + now ); if (!updated) { throw new ApiException(WorkerImportErrorCode.IMPORT_VERSION_CONFLICT); diff --git a/src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportRepository.java b/src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportRepository.java index 6b3b0871..150dc842 100644 --- a/src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportRepository.java +++ b/src/main/java/com/fowoco/server/workerimport/application/port/WorkerImportRepository.java @@ -1,6 +1,7 @@ package com.fowoco.server.workerimport.application.port; import com.fowoco.server.workerimport.application.ImportValidationError; +import com.fowoco.server.workerimport.application.WorkerImportCommitRecord; import com.fowoco.server.workerimport.application.WorkerImportJobRecord; import com.fowoco.server.workerimport.application.WorkerImportRowRecord; import com.fowoco.server.workerimport.domain.WorkerImportField; @@ -19,6 +20,10 @@ public interface WorkerImportRepository { Optional findByCreateKey(UUID companyId, String keyHash); + Optional findCommitByKey(UUID companyId, UUID importId, String keyHash); + + void insertCommit(WorkerImportCommitRecord record); + List findRows(UUID companyId, UUID importId, int offset, int limit); List findAllRows(UUID companyId, UUID importId); @@ -35,8 +40,6 @@ boolean updateJob( int invalidRows, int excludedRows, int committedRows, - String commitKeyHash, - String commitRequestHash, Instant updatedAt ); diff --git a/src/main/java/com/fowoco/server/workerimport/infrastructure/persistence/JdbcWorkerImportRepository.java b/src/main/java/com/fowoco/server/workerimport/infrastructure/persistence/JdbcWorkerImportRepository.java index 2a44123a..b69db7df 100644 --- a/src/main/java/com/fowoco/server/workerimport/infrastructure/persistence/JdbcWorkerImportRepository.java +++ b/src/main/java/com/fowoco/server/workerimport/infrastructure/persistence/JdbcWorkerImportRepository.java @@ -1,8 +1,10 @@ package com.fowoco.server.workerimport.infrastructure.persistence; import com.fowoco.server.workerimport.application.ImportValidationError; +import com.fowoco.server.workerimport.application.WorkerImportCommitRecord; import com.fowoco.server.workerimport.application.WorkerImportJobRecord; import com.fowoco.server.workerimport.application.WorkerImportRowRecord; +import com.fowoco.server.workerimport.application.WorkerImportView; import com.fowoco.server.workerimport.application.port.WorkerImportRepository; import com.fowoco.server.workerimport.domain.WorkerImportField; import com.fowoco.server.workerimport.domain.WorkerImportRowStatus; @@ -28,8 +30,7 @@ public class JdbcWorkerImportRepository implements WorkerImportRepository { private static final String JOB_COLUMNS = """ import_id, company_id, source_file_id, created_by, status, source_headers_json, mapping_json, create_idempotency_key_hash, - create_request_hash, last_commit_idempotency_key_hash, - last_commit_request_hash, total_rows, valid_rows, invalid_rows, + create_request_hash, total_rows, valid_rows, invalid_rows, excluded_rows, committed_rows, source_file_expires_at, created_at, updated_at, version """; @@ -103,6 +104,43 @@ public Optional findByCreateKey(UUID companyId, String ke ).stream().findFirst(); } + @Override + public Optional findCommitByKey(UUID companyId, UUID importId, String keyHash) { + return jdbcTemplate.query( + """ + SELECT company_id, import_id, idempotency_key_hash, request_hash, + response_snapshot_json, created_at + FROM worker_import_commit_idempotency + WHERE company_id = ? AND import_id = ? AND idempotency_key_hash = ? + """, + (resultSet, ignored) -> new WorkerImportCommitRecord( + uuid(resultSet, "company_id"), + uuid(resultSet, "import_id"), + resultSet.getString("idempotency_key_hash"), + resultSet.getString("request_hash"), + decode(resultSet.getString("response_snapshot_json"), WorkerImportView.class), + instant(resultSet, "created_at") + ), + companyId, + importId, + keyHash + ).stream().findFirst(); + } + + @Override + public void insertCommit(WorkerImportCommitRecord record) { + jdbcTemplate.update( + """ + INSERT INTO worker_import_commit_idempotency ( + company_id, import_id, idempotency_key_hash, request_hash, + response_snapshot_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?) + """, + record.companyId(), record.importId(), record.idempotencyKeyHash(), record.requestHash(), + encode(record.responseSnapshot()), timestamp(record.createdAt()) + ); + } + @Override public List findRows(UUID companyId, UUID importId, int offset, int limit) { return jdbcTemplate.query( @@ -150,8 +188,6 @@ public boolean updateJob( int invalidRows, int excludedRows, int committedRows, - String commitKeyHash, - String commitRequestHash, Instant updatedAt ) { return jdbcTemplate.update( @@ -159,14 +195,11 @@ public boolean updateJob( UPDATE worker_import_job SET status = ?, mapping_json = ?, valid_rows = ?, invalid_rows = ?, excluded_rows = ?, committed_rows = ?, - last_commit_idempotency_key_hash = COALESCE(?, last_commit_idempotency_key_hash), - last_commit_request_hash = COALESCE(?, last_commit_request_hash), updated_at = ?, version = version + 1 WHERE company_id = ? AND import_id = ? AND version = ? """, status.name(), encodeMappings(mappings), validRows, invalidRows, - excludedRows, committedRows, commitKeyHash, commitRequestHash, - timestamp(updatedAt), companyId, importId, expectedVersion + excludedRows, committedRows, timestamp(updatedAt), companyId, importId, expectedVersion ) == 1; } @@ -209,8 +242,6 @@ private WorkerImportJobRecord mapJob(ResultSet resultSet, int rowNumber) throws decodeMappings(resultSet.getString("mapping_json")), resultSet.getString("create_idempotency_key_hash"), resultSet.getString("create_request_hash"), - resultSet.getString("last_commit_idempotency_key_hash"), - resultSet.getString("last_commit_request_hash"), resultSet.getInt("total_rows"), resultSet.getInt("valid_rows"), resultSet.getInt("invalid_rows"), @@ -301,6 +332,14 @@ private String encode(Object value) { } } + private T decode(String json, Class type) { + try { + return objectMapper.readValue(json, type); + } catch (JacksonException exception) { + throw new IllegalStateException("worker import snapshot cannot be decoded", exception); + } + } + private UUID uuid(ResultSet resultSet, String column) throws SQLException { Object value = resultSet.getObject(column); return value instanceof UUID uuid ? uuid : UUID.fromString(value.toString()); diff --git a/src/main/resources/db/migration-postgresql/V33__prepare_worker_import_rls.sql b/src/main/resources/db/migration-postgresql/V33__prepare_worker_import_rls.sql index cd20e11a..82b62447 100644 --- a/src/main/resources/db/migration-postgresql/V33__prepare_worker_import_rls.sql +++ b/src/main/resources/db/migration-postgresql/V33__prepare_worker_import_rls.sql @@ -19,3 +19,14 @@ CREATE POLICY pl_worker_import_row_tenant_isolation WITH CHECK ( company_id = NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID ); + +CREATE POLICY pl_worker_import_commit_idempotency_tenant_isolation + ON public.worker_import_commit_idempotency + FOR ALL + TO PUBLIC + USING ( + company_id = NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); diff --git a/src/main/resources/db/migration/V32__create_worker_import.sql b/src/main/resources/db/migration/V32__create_worker_import.sql index 4c92d202..1ead8271 100644 --- a/src/main/resources/db/migration/V32__create_worker_import.sql +++ b/src/main/resources/db/migration/V32__create_worker_import.sql @@ -8,8 +8,6 @@ CREATE TABLE worker_import_job ( mapping_json TEXT NOT NULL, create_idempotency_key_hash VARCHAR(64) NOT NULL, create_request_hash VARCHAR(64) NOT NULL, - last_commit_idempotency_key_hash VARCHAR(64), - last_commit_request_hash VARCHAR(64), total_rows INTEGER NOT NULL DEFAULT 0, valid_rows INTEGER NOT NULL DEFAULT 0, invalid_rows INTEGER NOT NULL DEFAULT 0, @@ -37,13 +35,6 @@ CREATE TABLE worker_import_job ( CHECK (CHAR_LENGTH(create_idempotency_key_hash) = 64), CONSTRAINT ck_worker_import_job_create_request_hash CHECK (CHAR_LENGTH(create_request_hash) = 64), - CONSTRAINT ck_worker_import_job_commit_hash_pair CHECK ( - (last_commit_idempotency_key_hash IS NULL AND last_commit_request_hash IS NULL) - OR ( - CHAR_LENGTH(last_commit_idempotency_key_hash) = 64 - AND CHAR_LENGTH(last_commit_request_hash) = 64 - ) - ), CONSTRAINT ck_worker_import_job_counts CHECK ( total_rows >= 0 AND valid_rows >= 0 AND invalid_rows >= 0 AND excluded_rows >= 0 AND committed_rows >= 0 @@ -90,6 +81,24 @@ CREATE TABLE worker_import_row ( CONSTRAINT ck_worker_import_row_time_order CHECK (updated_at >= created_at) ); +CREATE TABLE worker_import_commit_idempotency ( + company_id UUID NOT NULL, + import_id UUID NOT NULL, + idempotency_key_hash VARCHAR(64) NOT NULL, + request_hash VARCHAR(64) NOT NULL, + response_snapshot_json TEXT NOT NULL, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + CONSTRAINT pk_worker_import_commit_idempotency + PRIMARY KEY (company_id, import_id, idempotency_key_hash), + CONSTRAINT fk_worker_import_commit_idempotency_job_company + FOREIGN KEY (import_id, company_id) + REFERENCES worker_import_job (import_id, company_id) ON DELETE CASCADE, + CONSTRAINT ck_worker_import_commit_idempotency_key_hash + CHECK (CHAR_LENGTH(idempotency_key_hash) = 64), + CONSTRAINT ck_worker_import_commit_request_hash + CHECK (CHAR_LENGTH(request_hash) = 64) +); + CREATE INDEX idx_worker_import_job_company_updated ON worker_import_job (company_id, updated_at DESC, import_id); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 95f4ac16..9256f36c 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -107,7 +107,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "password_reset_token", "document_ocr_run", "worker_import_job", - "worker_import_row" + "worker_import_row", + "worker_import_commit_idempotency" ); assertThat(columnSpecs(connection, "company")) @@ -321,6 +322,13 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("validation_errors_json", new ColumnSpec("text", false)) .containsEntry("worker_id", new ColumnSpec("uuid", true)) .containsEntry("version", new ColumnSpec("int8", false)); + assertThat(columnSpecs(connection, "worker_import_commit_idempotency")) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("import_id", new ColumnSpec("uuid", false)) + .containsEntry("idempotency_key_hash", new ColumnSpec("varchar", false)) + .containsEntry("request_hash", new ColumnSpec("varchar", false)) + .containsEntry("response_snapshot_json", new ColumnSpec("text", false)) + .containsEntry("created_at", new ColumnSpec("timestamptz", false)); assertThat(constraintNames(connection)) .contains( @@ -407,7 +415,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pk_worker_import_row", "uq_worker_import_row_number", "fk_worker_import_row_job_company", - "fk_worker_import_row_worker_company" + "fk_worker_import_row_worker_company", + "pk_worker_import_commit_idempotency", + "fk_worker_import_commit_idempotency_job_company" ); assertThat(indexNames(connection)) .contains( @@ -484,7 +494,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_password_reset_token_tenant_isolation", "pl_document_ocr_run_tenant_isolation", "pl_worker_import_job_tenant_isolation", - "pl_worker_import_row_tenant_isolation" + "pl_worker_import_row_tenant_isolation", + "pl_worker_import_commit_idempotency_tenant_isolation" ); assertThat(rlsEnabledTables(connection)).isEmpty(); assertThat(securityDefinerFunctionNames(connection)) diff --git a/src/test/java/com/fowoco/server/workerimport/PostgreSqlWorkerImportRlsTest.java b/src/test/java/com/fowoco/server/workerimport/PostgreSqlWorkerImportRlsTest.java index d7d93e8c..d52fae60 100644 --- a/src/test/java/com/fowoco/server/workerimport/PostgreSqlWorkerImportRlsTest.java +++ b/src/test/java/com/fowoco/server/workerimport/PostgreSqlWorkerImportRlsTest.java @@ -26,7 +26,11 @@ class PostgreSqlWorkerImportRlsTest { private static final UUID FILE_B = UUID.fromString("e6000000-0000-0000-0000-000000000002"); private static final UUID IMPORT_A = UUID.fromString("e7000000-0000-0000-0000-000000000001"); private static final UUID IMPORT_B = UUID.fromString("e8000000-0000-0000-0000-000000000002"); - private static final List TABLES = List.of("worker_import_job", "worker_import_row"); + private static final List TABLES = List.of( + "worker_import_job", + "worker_import_row", + "worker_import_commit_idempotency" + ); @Test void importTablesFailClosedAndHideOtherCompanyRows() throws Exception { @@ -53,6 +57,7 @@ void importTablesFailClosedAndHideOtherCompanyRows() throws Exception { bind(runtime, COMPANY_A); assertThat(count(runtime, "SELECT COUNT(*) FROM worker_import_job")).isEqualTo(1); assertThat(count(runtime, "SELECT COUNT(*) FROM worker_import_row")).isEqualTo(1); + assertThat(count(runtime, "SELECT COUNT(*) FROM worker_import_commit_idempotency")).isEqualTo(1); runtime.rollback(); bind(runtime, COMPANY_A); @@ -90,7 +95,8 @@ private void prepare(Connection connection, String role, String password) throws + " TO " + quoteIdentifier(role)); statement.execute("GRANT USAGE ON SCHEMA public TO " + quoteIdentifier(role)); statement.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.worker_import_job," - + " public.worker_import_row TO " + quoteIdentifier(role)); + + " public.worker_import_row, public.worker_import_commit_idempotency TO " + + quoteIdentifier(role)); statement.execute("INSERT INTO company (company_id, name, status) VALUES " + "('" + COMPANY_A + "', 'Import RLS A', 'ACTIVE')," + "('" + COMPANY_B + "', 'Import RLS B', 'ACTIVE')"); @@ -106,6 +112,8 @@ private void prepare(Connection connection, String role, String password) throws insertJob(statement, IMPORT_B, COMPANY_B, FILE_B, USER_B, "b"); insertRow(statement, "ea000000-0000-0000-0000-000000000001", IMPORT_A, COMPANY_A); insertRow(statement, "eb000000-0000-0000-0000-000000000002", IMPORT_B, COMPANY_B); + insertCommit(statement, IMPORT_A, COMPANY_A, "c"); + insertCommit(statement, IMPORT_B, COMPANY_B, "d"); } } @@ -127,6 +135,13 @@ private void insertRow(Statement statement, String rowId, UUID importId, UUID co + "',2,'{}','{}','{}','[]','PENDING',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"); } + private void insertCommit(Statement statement, UUID importId, UUID companyId, String key) throws SQLException { + statement.execute("INSERT INTO worker_import_commit_idempotency " + + "(company_id, import_id, idempotency_key_hash, request_hash, response_snapshot_json, created_at) VALUES " + + "('" + companyId + "','" + importId + "',repeat('" + key + "',64),repeat('" + key + + "',64),'{}',CURRENT_TIMESTAMP)"); + } + private void bind(Connection connection, UUID companyId) throws SQLException { try (var statement = connection.prepareStatement("SELECT set_config('app.company_id', ?, true)")) { statement.setString(1, companyId.toString()); @@ -150,6 +165,8 @@ private void cleanup(Connection connection, String role) throws SQLException { } private void cleanupRows(Statement statement) throws SQLException { + statement.execute("DELETE FROM worker_import_commit_idempotency WHERE company_id IN ('" + + COMPANY_A + "','" + COMPANY_B + "')"); statement.execute("DELETE FROM worker_import_row WHERE company_id IN ('" + COMPANY_A + "','" + COMPANY_B + "')"); statement.execute("DELETE FROM worker_import_job WHERE company_id IN ('" + COMPANY_A + "','" + COMPANY_B + "')"); statement.execute("DELETE FROM stored_file WHERE company_id IN ('" + COMPANY_A + "','" + COMPANY_B + "')"); diff --git a/src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java b/src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java index ca3aeac3..59138def 100644 --- a/src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java +++ b/src/test/java/com/fowoco/server/workerimport/WorkerImportApiIntegrationTest.java @@ -72,6 +72,7 @@ void seedAccounts() { @BeforeEach void resetImports() { jdbcTemplate.update("DELETE FROM audit_event WHERE target_type = 'WORKER_IMPORT'"); + jdbcTemplate.update("DELETE FROM worker_import_commit_idempotency"); jdbcTemplate.update("DELETE FROM worker_import_row"); jdbcTemplate.update("DELETE FROM worker_import_job"); jdbcTemplate.update("DELETE FROM worker WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); @@ -142,15 +143,6 @@ void csvRowsAreReviewedFixedAndCommittedWithoutDuplicateReplay() throws Exceptio assertThat(JsonPath.read(firstCommit.body(), "$.committed_rows")).isEqualTo(1); assertThat(JsonPath.read(firstCommit.body(), "$.status")).isEqualTo("READY"); - HttpResponse replay = json( - "POST", "/api/v1/imports/" + importId + "/commit", tokenA, "worker-import-commit-0001", - "{\"expected_version\":4,\"selected_row_numbers\":[2]}" - ); - assertThat(replay.statusCode()).as(replay.body()).isEqualTo(200); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM worker WHERE company_id = ?", Integer.class, COMPANY_A - )).isEqualTo(1); - HttpResponse finalCommit = json( "POST", "/api/v1/imports/" + importId + "/commit", tokenA, "worker-import-commit-0002", "{\"expected_version\":5,\"selected_row_numbers\":[3]}" @@ -162,6 +154,29 @@ void csvRowsAreReviewedFixedAndCommittedWithoutDuplicateReplay() throws Exceptio "SELECT COUNT(*) FROM worker WHERE company_id = ?", Integer.class, COMPANY_A )).isEqualTo(2); + HttpResponse replayFirstCommit = json( + "POST", "/api/v1/imports/" + importId + "/commit", tokenA, "worker-import-commit-0001", + "{\"expected_version\":4,\"selected_row_numbers\":[2]}" + ); + assertThat(replayFirstCommit.statusCode()).as(replayFirstCommit.body()).isEqualTo(200); + assertThat(replayFirstCommit.body()).isEqualTo(firstCommit.body()); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker WHERE company_id = ?", Integer.class, COMPANY_A + )).isEqualTo(2); + + HttpResponse conflictingReplay = json( + "POST", "/api/v1/imports/" + importId + "/commit", tokenA, "worker-import-commit-0001", + "{\"expected_version\":6,\"selected_row_numbers\":[3]}" + ); + assertThat(conflictingReplay.statusCode()).as(conflictingReplay.body()).isEqualTo(409); + assertThat(JsonPath.read(conflictingReplay.body(), "$.code")) + .isEqualTo("IMPORT_IDEMPOTENCY_CONFLICT"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_import_commit_idempotency WHERE import_id = ?", + Integer.class, + importId + )).isEqualTo(2); + String tokenB = accessToken(login(HR_B_EMAIL)); HttpResponse otherTenant = get("/api/v1/imports/" + importId, tokenB); assertThat(otherTenant.statusCode()).isEqualTo(404); @@ -309,6 +324,11 @@ INSERT INTO user_account ( private void deleteFixtures() { jdbcTemplate.update("DELETE FROM audit_event WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update( + "DELETE FROM worker_import_commit_idempotency WHERE company_id IN (?, ?)", + COMPANY_A, + COMPANY_B + ); jdbcTemplate.update("DELETE FROM worker_import_row WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); jdbcTemplate.update("DELETE FROM worker_import_job WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); jdbcTemplate.update("DELETE FROM worker WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); From ef2f51fb2a3a21ac424b356a15622bf74fc4c8a0 Mon Sep 17 00:00:00 2001 From: hywznn Date: Sat, 8 Aug 2026 02:41:32 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix(import):=20XLSX=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EC=85=80=EC=9D=84=20ISO=20=ED=98=95=EC=8B=9D=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=95=EA=B7=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultWorkerImportFileParser.java | 4 +++ .../DefaultWorkerImportFileParserTest.java | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/main/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParser.java b/src/main/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParser.java index bdc03dc8..e7c1283a 100644 --- a/src/main/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParser.java +++ b/src/main/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParser.java @@ -21,6 +21,7 @@ import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellType; import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.DateUtil; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; @@ -156,6 +157,9 @@ private ParsedWorkerImport parseXlsx(byte[] content) { values.add(""); } else if (sourceCell.getCellType() == CellType.FORMULA) { throw new ApiException(WorkerImportErrorCode.IMPORT_FORMULA_NOT_ALLOWED); + } else if (sourceCell.getCellType() == CellType.NUMERIC + && DateUtil.isCellDateFormatted(sourceCell)) { + values.add(sourceCell.getLocalDateTimeCellValue().toLocalDate().toString()); } else { values.add(formatter.formatCellValue(sourceCell)); } diff --git a/src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java b/src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java index 6c7116d6..4e945720 100644 --- a/src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java +++ b/src/test/java/com/fowoco/server/workerimport/infrastructure/parsing/DefaultWorkerImportFileParserTest.java @@ -7,6 +7,7 @@ import com.fowoco.server.workerimport.application.error.WorkerImportErrorCode; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.Test; @@ -81,4 +82,37 @@ void acceptsExactlyOneThousandDataRows() { assertThat(parser.parse("workers.csv", csv.toString().getBytes(StandardCharsets.UTF_8)).rows()) .hasSize(1_000); } + + @Test + void normalizesXlsxDateCellsToIsoDateRegardlessOfDisplayFormat() throws Exception { + byte[] content; + try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + var sheet = workbook.createSheet("workers"); + var header = sheet.createRow(0); + header.createCell(0).setCellValue("이름"); + header.createCell(1).setCellValue("체류만료일"); + + var slashStyle = workbook.createCellStyle(); + slashStyle.setDataFormat(workbook.createDataFormat().getFormat("m/d/yy")); + var first = sheet.createRow(1); + first.createCell(0).setCellValue("응웬반안"); + first.createCell(1).setCellValue(LocalDate.of(2027, 1, 2)); + first.getCell(1).setCellStyle(slashStyle); + + var dottedStyle = workbook.createCellStyle(); + dottedStyle.setDataFormat(workbook.createDataFormat().getFormat("yyyy. m. d")); + var second = sheet.createRow(2); + second.createCell(0).setCellValue("쩐티비"); + second.createCell(1).setCellValue(LocalDate.of(2027, 12, 31)); + second.getCell(1).setCellStyle(dottedStyle); + + workbook.write(output); + content = output.toByteArray(); + } + + var result = parser.parse("workers.xlsx", content); + + assertThat(result.rows().get(0).get("체류만료일")).isEqualTo("2027-01-02"); + assertThat(result.rows().get(1).get("체류만료일")).isEqualTo("2027-12-31"); + } } From 7ec2f233639f17492bb90886366004378bf79bfc Mon Sep 17 00:00:00 2001 From: hywznn Date: Sat, 8 Aug 2026 02:41:32 +0900 Subject: [PATCH 9/9] =?UTF-8?q?docs(import):=20=EB=A9=B1=EB=93=B1=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=EC=99=80=20XLSX=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EA=B7=9C=EC=B9=99=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/worker-import.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/worker-import.md b/docs/worker-import.md index 3f2a7b59..dc636b96 100644 --- a/docs/worker-import.md +++ b/docs/worker-import.md @@ -98,12 +98,13 @@ Idempotency-Key: commit-20260807-001 } ``` -`selected_row_numbers`를 생략하면 현재 `VALID`인 모든 행을 등록합니다. 같은 `Idempotency-Key`와 같은 선택 행을 다시 보내도 이미 등록된 근로자를 중복 생성하지 않습니다. +`selected_row_numbers`를 생략하면 현재 `VALID`인 모든 행을 등록합니다. 등록에 사용한 모든 `Idempotency-Key`와 요청·응답 snapshot을 별도 기록하므로 부분 등록을 여러 번 수행한 뒤 이전 요청을 다시 보내도 당시 성공 응답을 반환하며, 같은 키로 다른 행을 요청하면 `409`로 거부합니다. ## 파일·개인정보 규칙 - 최대 5MB, 1,000개 데이터 행, 50개 열까지만 허용합니다. - UTF-8 CSV와 매크로가 없는 XLSX만 지원합니다. +- XLSX 날짜 셀은 화면 표시 형식과 관계없이 `YYYY-MM-DD`로 정규화합니다. - CSV 수식 시작 문자(`=`, `+`, `-`, `@`)와 XLSX Formula Cell을 거부합니다. - 여권번호·외국인등록번호·계좌번호와 MVP에서 수집하지 않는 연락처·이메일·주소·생년월일·법정실명 열은 업로드 단계에서 거부합니다. - 원본 행 전체를 일반 로그나 감사로그에 남기지 않습니다.