feat: 엑셀 업로드 템플릿 시스템 (V19)
Agent Teams 3명 병렬 작업 (dba / api / frontend) 결과물.
DB (V19__업로드템플릿.sql):
- upload_template / upload_template_column / upload_history 3 테이블
- 인덱스 5종 (category / company_code / template_id / started_at)
- SYSTEM_UPLOAD_TEMPLATE 메뉴 + READ/CREATE/UPDATE/DELETE 권한
- 모든 역할에 권한 부여 (MANAGER 는 READ 만)
Backend:
- ga-core: VO 7개 (Template/Column/History의 VO/Resp/SaveReq/SearchParam) +
Mapper 3개 + XML 3개 (UploadTemplate/Column/History)
- ga-api: TransformEngine (12 변환 규칙: TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/
LOOKUP/CODE_MAP/FIXED/REPLACE/SUBSTRING/CONCAT/DEFAULT) +
LookupCache (O(1) HashMap 캐시) + UploadService (processUpload/preview) +
LookupMapper / DynamicInsertMapper (whitelist 기반 동적 SQL)
- ga-api Controller 2개:
· UploadTemplateController: /api/upload-templates CRUD + columns + history
· UploadController: POST /api/upload/{templateCode}, /preview
Frontend:
- api/uploadTemplate.ts (10 endpoints)
- pages/system/UploadTemplateManager.tsx — 좌(템플릿 목록) 우(컬럼 매핑 편집기)
+ ModalForm 등록/수정 + 미리보기 Modal + 이력 Drawer
- components/common/TemplateUpload.tsx — 범용 컴포넌트 (Select 템플릿 + Dragger
+ 진행률 + 결과 Modal + 에러 파일 다운로드)
- App.tsx 라우트 추가, MenuIcon.tsx 매핑 추가
검증:
- ./gradlew :ga-api:bootJar 통과
- Flyway V19 운영 DB 적용 성공 (200ms)
- ga-api 부팅 7.8s, /api/upload-templates 200 응답
- 메뉴 트리에 SYSTEM_UPLOAD_TEMPLATE 노출 확인
- 프론트 tsc -b / vite build 통과
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package com.ga.api.controller.upload;
|
||||
|
||||
import com.ga.api.service.upload.UploadResultResp;
|
||||
import com.ga.api.service.upload.UploadService;
|
||||
import com.ga.common.annotation.RequirePermission;
|
||||
import com.ga.common.model.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "엑셀 업로드")
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class UploadController {
|
||||
|
||||
private final UploadService uploadService;
|
||||
|
||||
@Operation(summary = "엑셀 업로드 처리",
|
||||
description = "templateCode 에 해당하는 템플릿으로 파일을 파싱 · 변환 · 배치 INSERT 한다.")
|
||||
@PostMapping("/api/upload/{templateCode}")
|
||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "CREATE")
|
||||
public ApiResponse<UploadResultResp> upload(@PathVariable String templateCode,
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
UploadResultResp result = uploadService.processUpload(templateCode, file);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "업로드 미리보기 (상위 5행)",
|
||||
description = "DB 에 저장하지 않고 변환 결과만 반환한다. limit 파라미터로 행 수 조정 가능.")
|
||||
@PostMapping("/api/upload-templates/{templateCode}/preview")
|
||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
||||
public ApiResponse<List<Map<String, Object>>> preview(
|
||||
@PathVariable String templateCode,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "limit", defaultValue = "5") int limit) {
|
||||
List<Map<String, Object>> rows = uploadService.preview(templateCode, file, limit);
|
||||
return ApiResponse.ok(rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.ga.api.controller.upload;
|
||||
|
||||
import com.ga.common.annotation.DataChangeLog;
|
||||
import com.ga.common.annotation.RequirePermission;
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
import com.ga.common.model.ApiResponse;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.common.util.SecurityUtil;
|
||||
import com.ga.core.mapper.upload.UploadColumnMapper;
|
||||
import com.ga.core.mapper.upload.UploadHistoryMapper;
|
||||
import com.ga.core.mapper.upload.UploadTemplateMapper;
|
||||
import com.ga.core.vo.upload.*;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Tag(name = "업로드 템플릿 관리")
|
||||
@RestController
|
||||
@RequestMapping("/api/upload-templates")
|
||||
@RequiredArgsConstructor
|
||||
public class UploadTemplateController {
|
||||
|
||||
private final UploadTemplateMapper templateMapper;
|
||||
private final UploadColumnMapper columnMapper;
|
||||
private final UploadHistoryMapper historyMapper;
|
||||
|
||||
@Operation(summary = "템플릿 목록 조회")
|
||||
@GetMapping
|
||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
||||
public ApiResponse<PageResponse<UploadTemplateResp>> list(@ModelAttribute UploadTemplateSearchParam param) {
|
||||
param.startPage();
|
||||
return ApiResponse.ok(PageResponse.of(templateMapper.selectList(param)));
|
||||
}
|
||||
|
||||
@Operation(summary = "템플릿 상세 조회 (컬럼 포함)")
|
||||
@GetMapping("/{templateId}")
|
||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
||||
public ApiResponse<UploadTemplateResp> detail(@PathVariable Long templateId) {
|
||||
UploadTemplateResp resp = templateMapper.selectById(templateId);
|
||||
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||
resp.setColumns(columnMapper.selectByTemplateId(templateId));
|
||||
return ApiResponse.ok(resp);
|
||||
}
|
||||
|
||||
@Operation(summary = "템플릿 등록")
|
||||
@PostMapping
|
||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "CREATE")
|
||||
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
|
||||
@Transactional
|
||||
public ApiResponse<Long> create(@Valid @RequestBody UploadTemplateSaveReq req) {
|
||||
if (templateMapper.existsByCode(req.getTemplateCode()) > 0) {
|
||||
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 존재하는 템플릿 코드입니다: " + req.getTemplateCode());
|
||||
}
|
||||
UploadTemplateVO vo = req.toVO();
|
||||
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
|
||||
templateMapper.insert(vo);
|
||||
|
||||
if (req.getColumns() != null && !req.getColumns().isEmpty()) {
|
||||
columnMapper.insertBatch(vo.getTemplateId(), req.getColumns());
|
||||
}
|
||||
return ApiResponse.ok(vo.getTemplateId());
|
||||
}
|
||||
|
||||
@Operation(summary = "템플릿 수정")
|
||||
@PutMapping("/{templateId}")
|
||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE")
|
||||
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
|
||||
@Transactional
|
||||
public ApiResponse<Void> update(@PathVariable Long templateId,
|
||||
@Valid @RequestBody UploadTemplateSaveReq req) {
|
||||
if (templateMapper.selectById(templateId) == null) {
|
||||
throw new BizException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
UploadTemplateVO vo = req.toVO();
|
||||
vo.setTemplateId(templateId);
|
||||
vo.setUpdatedBy(SecurityUtil.getCurrentUserId());
|
||||
templateMapper.update(vo);
|
||||
|
||||
if (req.getColumns() != null) {
|
||||
columnMapper.deleteByTemplateId(templateId);
|
||||
if (!req.getColumns().isEmpty()) {
|
||||
columnMapper.insertBatch(templateId, req.getColumns());
|
||||
}
|
||||
}
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "템플릿 삭제")
|
||||
@DeleteMapping("/{templateId}")
|
||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "DELETE")
|
||||
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
|
||||
@Transactional
|
||||
public ApiResponse<Void> delete(@PathVariable Long templateId) {
|
||||
if (templateMapper.selectById(templateId) == null) {
|
||||
throw new BizException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
columnMapper.deleteByTemplateId(templateId);
|
||||
templateMapper.delete(templateId);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "템플릿 컬럼 목록 조회")
|
||||
@GetMapping("/{templateId}/columns")
|
||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
||||
public ApiResponse<List<UploadTemplateColumnVO>> columns(@PathVariable Long templateId) {
|
||||
return ApiResponse.ok(columnMapper.selectByTemplateId(templateId));
|
||||
}
|
||||
|
||||
@Operation(summary = "템플릿 컬럼 전체 갱신 (기존 삭제 후 재등록)")
|
||||
@PutMapping("/{templateId}/columns")
|
||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE")
|
||||
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template_column")
|
||||
@Transactional
|
||||
public ApiResponse<Void> updateColumns(@PathVariable Long templateId,
|
||||
@RequestBody List<UploadTemplateColumnVO> columns) {
|
||||
if (templateMapper.selectById(templateId) == null) {
|
||||
throw new BizException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
columnMapper.deleteByTemplateId(templateId);
|
||||
if (columns != null && !columns.isEmpty()) {
|
||||
columnMapper.insertBatch(templateId, columns);
|
||||
}
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "템플릿 업로드 이력 조회")
|
||||
@GetMapping("/{templateId}/history")
|
||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
||||
public ApiResponse<PageResponse<UploadHistoryResp>> history(@PathVariable Long templateId,
|
||||
@ModelAttribute UploadTemplateSearchParam param) {
|
||||
param.startPage();
|
||||
return ApiResponse.ok(PageResponse.of(historyMapper.selectByTemplate(templateId, param)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ga.api.service.upload;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 업로드 템플릿에 따른 동적 INSERT 매퍼.
|
||||
*
|
||||
* targetTable 은 반드시 UploadService 의 whitelist 검증 후 전달해야 한다.
|
||||
* (SQL Injection 방지)
|
||||
*/
|
||||
@Mapper
|
||||
public interface DynamicInsertMapper {
|
||||
|
||||
int insertRow(@Param("targetTable") String targetTable,
|
||||
@Param("row") Map<String, Object> row);
|
||||
|
||||
int insertBatch(@Param("targetTable") String targetTable,
|
||||
@Param("rows") List<Map<String, Object>> rows);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.ga.api.service.upload;
|
||||
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
import com.ga.core.vo.upload.UploadTemplateColumnVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* LOOKUP 변환 규칙 최적화 캐시.
|
||||
*
|
||||
* processUpload() 시작 시 LOOKUP 컬럼의 참조 테이블을 한 번에 로드하여
|
||||
* 행 처리마다 DB 조회하는 것을 방지한다 (70만건 × DB 조회 = 지옥 → HashMap.get() = 즉시).
|
||||
*
|
||||
* 캐시 구조: Map< "table:sourceField:targetField" → Map<sourceValue, targetValue> >
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class LookupCache {
|
||||
|
||||
private final LookupMapper lookupMapper;
|
||||
|
||||
/** 허용 테이블 whitelist (SQL Injection 방지) */
|
||||
private static final Set<String> ALLOWED_TABLES = Set.of(
|
||||
"recruit_ledger", "maintain_ledger", "contract", "agent",
|
||||
"commission_rate", "payout_rule", "chargeback_rule",
|
||||
"exception_ledger", "payment",
|
||||
"common_code", "organization", "grade", "insurance_company", "product"
|
||||
);
|
||||
|
||||
/** 컬럼 이름 허용 패턴 (영문/숫자/밑줄만) */
|
||||
private static final Pattern FIELD_PATTERN =
|
||||
Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,63}$");
|
||||
|
||||
// 요청 범위 캐시 (processUpload 한 번에만 유효)
|
||||
private final Map<String, Map<String, Object>> cache = new HashMap<>();
|
||||
|
||||
/**
|
||||
* LOOKUP 컬럼 목록에서 필요한 참조 데이터를 한 번에 preload.
|
||||
*/
|
||||
public void preload(List<UploadTemplateColumnVO> columns) {
|
||||
cache.clear();
|
||||
|
||||
columns.stream()
|
||||
.filter(c -> "LOOKUP".equalsIgnoreCase(c.getTransformRule()))
|
||||
.forEach(c -> {
|
||||
String key = buildKey(c.getLookupTable(), c.getLookupSourceField(), c.getLookupTargetField());
|
||||
if (!cache.containsKey(key)) {
|
||||
validateTableAndFields(c.getLookupTable(), c.getLookupSourceField(), c.getLookupTargetField());
|
||||
|
||||
List<Map<String, Object>> rows = lookupMapper.selectAll(
|
||||
c.getLookupTable(), c.getLookupSourceField(), c.getLookupTargetField());
|
||||
|
||||
Map<String, Object> map = new HashMap<>(rows.size() * 2);
|
||||
for (Map<String, Object> row : rows) {
|
||||
Object src = row.get("source_value");
|
||||
Object tgt = row.get("target_value");
|
||||
if (src != null) {
|
||||
map.put(src.toString(), tgt);
|
||||
}
|
||||
}
|
||||
cache.put(key, map);
|
||||
log.debug("LookupCache preloaded: {} → {} entries", key, map.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1) 조회.
|
||||
*/
|
||||
public Object lookup(String table, String sourceField, String value, String targetField) {
|
||||
if (value == null) return null;
|
||||
String key = buildKey(table, sourceField, targetField);
|
||||
Map<String, Object> map = cache.get(key);
|
||||
if (map == null) return null;
|
||||
return map.get(value);
|
||||
}
|
||||
|
||||
private String buildKey(String table, String sourceField, String targetField) {
|
||||
return table + ":" + sourceField + ":" + targetField;
|
||||
}
|
||||
|
||||
private void validateTableAndFields(String table, String sourceField, String targetField) {
|
||||
if (table == null || !ALLOWED_TABLES.contains(table)) {
|
||||
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
||||
"LOOKUP 허용되지 않는 테이블: " + table);
|
||||
}
|
||||
if (sourceField == null || !FIELD_PATTERN.matcher(sourceField).matches()) {
|
||||
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
||||
"LOOKUP 허용되지 않는 sourceField: " + sourceField);
|
||||
}
|
||||
if (targetField == null || !FIELD_PATTERN.matcher(targetField).matches()) {
|
||||
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
||||
"LOOKUP 허용되지 않는 targetField: " + targetField);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ga.api.service.upload;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* LOOKUP 변환 규칙에 사용하는 동적 SELECT 매퍼.
|
||||
*
|
||||
* table/field 는 반드시 LookupCache 의 whitelist 검증 후 전달해야 한다.
|
||||
* (${} 로 동적 테이블/컬럼 이름 처리 — SQL Injection 방지 필수)
|
||||
*/
|
||||
@Mapper
|
||||
public interface LookupMapper {
|
||||
|
||||
List<Map<String, Object>> selectAll(@Param("table") String table,
|
||||
@Param("sourceField") String sourceField,
|
||||
@Param("targetField") String targetField);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.ga.api.service.upload;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
import com.ga.core.vo.upload.UploadTemplateColumnVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.DateUtil;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 12종 변환 규칙 실행기.
|
||||
*
|
||||
* 지원 규칙: TRIM / UPPER / LOWER / DATE / DIVIDE / MULTIPLY /
|
||||
* LOOKUP / CODE_MAP / FIXED / REPLACE / SUBSTRING / CONCAT / DEFAULT
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TransformEngine {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final LookupCache lookupCache;
|
||||
|
||||
/**
|
||||
* 엑셀 Row 를 템플릿 컬럼 정의에 따라 변환하여 Map 으로 반환.
|
||||
*
|
||||
* @param row xlsx-streamer Row
|
||||
* @param columns 템플릿 컬럼 정의
|
||||
* @return { targetField → 변환된 값 }
|
||||
*/
|
||||
public Map<String, Object> transform(Row row, List<UploadTemplateColumnVO> columns) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
for (UploadTemplateColumnVO col : columns) {
|
||||
try {
|
||||
Object rawValue = extractRaw(row, col);
|
||||
Object transformed = applyTransform(rawValue, col, row, columns);
|
||||
result.put(col.getTargetField(), transformed);
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL,
|
||||
"컬럼[" + col.getTargetField() + "] 변환 오류: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 컬럼에 변환 규칙을 적용.
|
||||
*/
|
||||
public Object applyTransform(Object value, UploadTemplateColumnVO col,
|
||||
Row row, List<UploadTemplateColumnVO> allColumns) {
|
||||
String rule = col.getTransformRule();
|
||||
|
||||
// FIXED 는 원본 값 무시
|
||||
if ("FIXED".equalsIgnoreCase(rule)) {
|
||||
return col.getFixedValue();
|
||||
}
|
||||
|
||||
// DEFAULT: 값 없을 때만 적용
|
||||
if ("DEFAULT".equalsIgnoreCase(rule)) {
|
||||
return (value == null || value.toString().isBlank()) ? col.getDefaultValue() : value;
|
||||
}
|
||||
|
||||
if (rule == null || rule.isBlank()) {
|
||||
return applyTargetTypeCast(value, col);
|
||||
}
|
||||
|
||||
return switch (rule.toUpperCase()) {
|
||||
case "TRIM" -> value == null ? null : value.toString().trim();
|
||||
case "UPPER" -> value == null ? null : value.toString().trim().toUpperCase();
|
||||
case "LOWER" -> value == null ? null : value.toString().trim().toLowerCase();
|
||||
case "DATE" -> applyDate(value, col);
|
||||
case "DIVIDE" -> applyDivide(value, col);
|
||||
case "MULTIPLY" -> applyMultiply(value, col);
|
||||
case "LOOKUP" -> applyLookup(value, col);
|
||||
case "CODE_MAP" -> applyCodeMap(value, col);
|
||||
case "REPLACE" -> applyReplace(value, col);
|
||||
case "SUBSTRING" -> applySubstring(value, col);
|
||||
case "CONCAT" -> applyConcat(row, col);
|
||||
default -> applyTargetTypeCast(value, col);
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Raw 값 추출
|
||||
// =========================================================
|
||||
|
||||
private Object extractRaw(Row row, UploadTemplateColumnVO col) {
|
||||
if ("FIXED".equalsIgnoreCase(col.getSourceType())) {
|
||||
return col.getFixedValue();
|
||||
}
|
||||
if ("EXPRESSION".equalsIgnoreCase(col.getSourceType())) {
|
||||
return col.getFixedValue();
|
||||
}
|
||||
|
||||
// COLUMN: sourceColumn (0-based index)
|
||||
Integer srcCol = col.getSourceColumn();
|
||||
if (srcCol == null) return null;
|
||||
|
||||
Cell cell = row.getCell(srcCol);
|
||||
if (cell == null) return null;
|
||||
|
||||
return readCell(cell);
|
||||
}
|
||||
|
||||
private Object readCell(Cell cell) {
|
||||
return switch (cell.getCellType()) {
|
||||
case STRING -> {
|
||||
String v = cell.getStringCellValue();
|
||||
yield (v == null || v.isBlank()) ? null : v;
|
||||
}
|
||||
case NUMERIC -> {
|
||||
if (DateUtil.isCellDateFormatted(cell)) {
|
||||
yield cell.getDateCellValue().toInstant()
|
||||
.atZone(java.time.ZoneId.systemDefault()).toLocalDate();
|
||||
}
|
||||
double d = cell.getNumericCellValue();
|
||||
yield (d == Math.floor(d) && !Double.isInfinite(d))
|
||||
? String.valueOf((long) d)
|
||||
: String.valueOf(d);
|
||||
}
|
||||
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
|
||||
case FORMULA -> {
|
||||
try { yield cell.getStringCellValue(); }
|
||||
catch (Exception e) { yield String.valueOf(cell.getNumericCellValue()); }
|
||||
}
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 개별 변환 규칙
|
||||
// =========================================================
|
||||
|
||||
private Object applyDate(Object value, UploadTemplateColumnVO col) {
|
||||
if (value == null) return null;
|
||||
if (value instanceof LocalDate) return value.toString();
|
||||
String s = value.toString().trim();
|
||||
if (s.isBlank()) return null;
|
||||
String fmt = col.getDateFormat() != null ? col.getDateFormat() : "yyyyMMdd";
|
||||
try {
|
||||
LocalDate d = LocalDate.parse(s, DateTimeFormatter.ofPattern(fmt));
|
||||
return d.toString();
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL,
|
||||
"날짜 파싱 실패 [" + s + "] format=" + fmt);
|
||||
}
|
||||
}
|
||||
|
||||
private Object applyDivide(Object value, UploadTemplateColumnVO col) {
|
||||
if (value == null) return null;
|
||||
Integer divisor = col.getNumberDivide();
|
||||
if (divisor == null || divisor == 0) return value;
|
||||
BigDecimal bd = toBigDecimal(value);
|
||||
return bd.divide(BigDecimal.valueOf(divisor), 10, java.math.RoundingMode.HALF_UP)
|
||||
.stripTrailingZeros();
|
||||
}
|
||||
|
||||
private Object applyMultiply(Object value, UploadTemplateColumnVO col) {
|
||||
if (value == null) return null;
|
||||
Integer multiplier = col.getNumberMultiply();
|
||||
if (multiplier == null) return value;
|
||||
BigDecimal bd = toBigDecimal(value);
|
||||
return bd.multiply(BigDecimal.valueOf(multiplier)).stripTrailingZeros();
|
||||
}
|
||||
|
||||
private Object applyLookup(Object value, UploadTemplateColumnVO col) {
|
||||
if (value == null) return null;
|
||||
Object result = lookupCache.lookup(
|
||||
col.getLookupTable(),
|
||||
col.getLookupSourceField(),
|
||||
value.toString(),
|
||||
col.getLookupTargetField());
|
||||
if (result == null && col.getDefaultValue() != null) {
|
||||
return col.getDefaultValue();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Object applyCodeMap(Object value, UploadTemplateColumnVO col) {
|
||||
if (value == null) return null;
|
||||
Object mapped = lookupCache.lookup(
|
||||
"common_code", "code", value.toString(), "code_name");
|
||||
return mapped != null ? mapped : (col.getDefaultValue() != null ? col.getDefaultValue() : value);
|
||||
}
|
||||
|
||||
private Object applyReplace(Object value, UploadTemplateColumnVO col) {
|
||||
if (value == null) return null;
|
||||
String s = value.toString();
|
||||
String param = col.getTransformParam();
|
||||
if (param == null || param.isBlank()) return s;
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(param);
|
||||
String find = node.path("find").asText("");
|
||||
String replace = node.path("replace").asText("");
|
||||
return s.replace(find, replace);
|
||||
} catch (Exception e) {
|
||||
log.warn("REPLACE param 파싱 실패: {}", param);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private Object applySubstring(Object value, UploadTemplateColumnVO col) {
|
||||
if (value == null) return null;
|
||||
String s = value.toString();
|
||||
String param = col.getTransformParam();
|
||||
if (param == null || param.isBlank()) return s;
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(param);
|
||||
int start = node.path("start").asInt(0);
|
||||
int end = node.path("end").asInt(s.length());
|
||||
end = Math.min(end, s.length());
|
||||
start = Math.max(0, Math.min(start, end));
|
||||
return s.substring(start, end);
|
||||
} catch (Exception e) {
|
||||
log.warn("SUBSTRING param 파싱 실패: {}", param);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private Object applyConcat(Row row, UploadTemplateColumnVO col) {
|
||||
String param = col.getTransformParam();
|
||||
if (param == null || param.isBlank()) return null;
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(param);
|
||||
JsonNode colsNode = node.path("columns");
|
||||
String separator = node.path("separator").asText("");
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (JsonNode c : colsNode) {
|
||||
int colIdx = c.asInt(-1);
|
||||
if (colIdx < 0) continue;
|
||||
Cell cell = row.getCell(colIdx);
|
||||
if (cell != null) {
|
||||
Object v = readCell(cell);
|
||||
if (v != null) {
|
||||
if (sb.length() > 0) sb.append(separator);
|
||||
sb.append(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("CONCAT param 파싱 실패: {}", param);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Object applyTargetTypeCast(Object value, UploadTemplateColumnVO col) {
|
||||
if (value == null) return null;
|
||||
String targetType = col.getTargetType();
|
||||
if (targetType == null) return value;
|
||||
String s = value.toString().trim();
|
||||
return switch (targetType.toUpperCase()) {
|
||||
case "NUMBER", "INTEGER" -> {
|
||||
try { yield Long.parseLong(s.replace(",", "")); }
|
||||
catch (NumberFormatException e) { yield s; }
|
||||
}
|
||||
case "DECIMAL" -> {
|
||||
try { yield new BigDecimal(s.replace(",", "")); }
|
||||
catch (NumberFormatException e) { yield s; }
|
||||
}
|
||||
case "DATE" -> applyDate(value, col);
|
||||
default -> s;
|
||||
};
|
||||
}
|
||||
|
||||
private BigDecimal toBigDecimal(Object value) {
|
||||
if (value instanceof BigDecimal bd) return bd;
|
||||
try {
|
||||
return new BigDecimal(value.toString().replace(",", "").trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL,
|
||||
"숫자 변환 실패: " + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ga.api.service.upload;
|
||||
|
||||
import com.ga.common.excel.ExcelErrorRow;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 업로드 처리 결과 응답.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class UploadResultResp {
|
||||
|
||||
private Long historyId;
|
||||
private int totalCount;
|
||||
private int successCount;
|
||||
private int errorCount;
|
||||
private int skipCount;
|
||||
private Long errorFileId;
|
||||
private int durationSec;
|
||||
|
||||
/** 에러 행 목록 (최대 maxErrorCount 까지) */
|
||||
private List<UploadErrorRow> errors;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public static class UploadErrorRow {
|
||||
private int rowNumber;
|
||||
private String fieldName;
|
||||
private String value;
|
||||
private String errorMessage;
|
||||
|
||||
public static UploadErrorRow from(ExcelErrorRow e) {
|
||||
return UploadErrorRow.builder()
|
||||
.rowNumber(e.getRowNumber())
|
||||
.fieldName(e.getFieldName())
|
||||
.value(e.getValue())
|
||||
.errorMessage(e.getErrorMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package com.ga.api.service.upload;
|
||||
|
||||
import com.ga.common.excel.ExcelErrorRow;
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
import com.ga.common.file.FileService;
|
||||
import com.ga.common.util.SecurityUtil;
|
||||
import com.ga.core.mapper.upload.UploadColumnMapper;
|
||||
import com.ga.core.mapper.upload.UploadHistoryMapper;
|
||||
import com.ga.core.mapper.upload.UploadTemplateMapper;
|
||||
import com.ga.core.vo.upload.UploadHistoryVO;
|
||||
import com.ga.core.vo.upload.UploadTemplateColumnVO;
|
||||
import com.ga.core.vo.upload.UploadTemplateVO;
|
||||
import com.monitorjbl.xlsx.StreamingReader;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.lang.NonNull;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 템플릿 처리 핵심 서비스.
|
||||
*
|
||||
* processUpload: SAX 파싱 + 변환 + 검증 + 배치 INSERT → upload_history 저장
|
||||
* preview: 첫 N행 변환 결과만 반환 (DB INSERT 없음)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UploadService {
|
||||
|
||||
private final UploadTemplateMapper templateMapper;
|
||||
private final UploadColumnMapper columnMapper;
|
||||
private final UploadHistoryMapper historyMapper;
|
||||
private final TransformEngine transformEngine;
|
||||
private final LookupCache lookupCache;
|
||||
private final DynamicInsertMapper dynamicInsertMapper;
|
||||
private final FileService fileService;
|
||||
|
||||
/** target_table whitelist — SQL Injection 방지 */
|
||||
private static final Set<String> ALLOWED_TARGET_TABLES = Set.of(
|
||||
"recruit_ledger", "maintain_ledger", "contract", "agent",
|
||||
"commission_rate", "payout_rule", "chargeback_rule",
|
||||
"exception_ledger", "payment"
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// 메인: 업로드 처리
|
||||
// ============================================================
|
||||
|
||||
@Transactional
|
||||
public UploadResultResp processUpload(String templateCode, MultipartFile file) {
|
||||
long startMs = System.currentTimeMillis();
|
||||
|
||||
// 1. 템플릿 + 컬럼 로드
|
||||
UploadTemplateVO template = loadTemplate(templateCode);
|
||||
List<UploadTemplateColumnVO> columns = columnMapper.selectByTemplateId(template.getTemplateId());
|
||||
if (columns.isEmpty()) {
|
||||
throw new BizException(ErrorCode.INVALID_PARAMETER, "컬럼 정의가 없는 템플릿입니다: " + templateCode);
|
||||
}
|
||||
|
||||
validateTargetTable(template.getTargetTable());
|
||||
|
||||
// 2. LOOKUP 캐시 사전 로드 (70만건 × DB 조회 방지)
|
||||
lookupCache.preload(columns);
|
||||
|
||||
// 3. history 레코드 생성 (PROCESSING 상태)
|
||||
UploadHistoryVO history = initHistory(template, file);
|
||||
historyMapper.insert(history);
|
||||
|
||||
List<ExcelErrorRow> errors = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
int skipCount = 0;
|
||||
int totalCount = 0;
|
||||
int maxErrors = template.getMaxErrorCount() != null ? template.getMaxErrorCount() : 100;
|
||||
int batchSize = template.getBatchSize() != null ? template.getBatchSize() : 1000;
|
||||
int dataStartRow = template.getDataStartRow() != null ? template.getDataStartRow() : 2;
|
||||
int sheetIndex = template.getSheetIndex() != null ? template.getSheetIndex() : 0;
|
||||
|
||||
List<Map<String, Object>> batch = new ArrayList<>(batchSize);
|
||||
|
||||
try (InputStream is = file.getInputStream();
|
||||
Workbook wb = StreamingReader.builder()
|
||||
.rowCacheSize(100)
|
||||
.bufferSize(4096)
|
||||
.open(is)) {
|
||||
|
||||
Sheet sheet = wb.getSheetAt(sheetIndex);
|
||||
int rowNum = 0;
|
||||
|
||||
for (Row row : sheet) {
|
||||
rowNum++;
|
||||
if (rowNum < dataStartRow) continue;
|
||||
|
||||
if ("Y".equalsIgnoreCase(template.getSkipEmptyRow()) && isEmptyRow(row)) {
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
totalCount++;
|
||||
|
||||
if (errors.size() >= maxErrors) {
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> mapped = transformEngine.transform(row, columns);
|
||||
validateRow(mapped, columns, rowNum, errors);
|
||||
|
||||
final int currentRow = rowNum;
|
||||
boolean rowHasError = errors.stream().anyMatch(e -> e.getRowNumber() == currentRow);
|
||||
if (!rowHasError) {
|
||||
batch.add(mapped);
|
||||
if (batch.size() >= batchSize) {
|
||||
dynamicInsertMapper.insertBatch(template.getTargetTable(), batch);
|
||||
successCount += batch.size();
|
||||
batch.clear();
|
||||
log.debug("Batch flush: {} rows processed so far", totalCount);
|
||||
}
|
||||
}
|
||||
} catch (BizException e) {
|
||||
errors.add(new ExcelErrorRow(rowNum, null, null, e.getDisplayMessage()));
|
||||
} catch (Exception e) {
|
||||
errors.add(new ExcelErrorRow(rowNum, null, null, e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
if (!batch.isEmpty()) {
|
||||
dynamicInsertMapper.insertBatch(template.getTargetTable(), batch);
|
||||
successCount += batch.size();
|
||||
batch.clear();
|
||||
}
|
||||
|
||||
} catch (BizException e) {
|
||||
updateHistoryFail(history.getHistoryId(), e.getDisplayMessage());
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("Upload processing error: templateCode={}", templateCode, e);
|
||||
updateHistoryFail(history.getHistoryId(), e.getMessage());
|
||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
|
||||
}
|
||||
|
||||
int durationSec = (int) ((System.currentTimeMillis() - startMs) / 1000);
|
||||
int errorCount = errors.size();
|
||||
String status = errorCount == 0 ? "SUCCESS" : (successCount > 0 ? "PARTIAL" : "FAIL");
|
||||
|
||||
Long errorFileId = null;
|
||||
if (!errors.isEmpty()) {
|
||||
errorFileId = createErrorFile(errors, file.getOriginalFilename());
|
||||
}
|
||||
|
||||
historyMapper.updateStatus(history.getHistoryId(), status, null,
|
||||
successCount, errorCount, skipCount, totalCount, errorFileId, durationSec);
|
||||
|
||||
return UploadResultResp.builder()
|
||||
.historyId(history.getHistoryId())
|
||||
.totalCount(totalCount)
|
||||
.successCount(successCount)
|
||||
.errorCount(errorCount)
|
||||
.skipCount(skipCount)
|
||||
.errorFileId(errorFileId)
|
||||
.durationSec(durationSec)
|
||||
.errors(errors.stream().map(UploadResultResp.UploadErrorRow::from).toList())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 미리보기: DB INSERT 없이 변환 결과만 반환
|
||||
// ============================================================
|
||||
|
||||
public List<Map<String, Object>> preview(String templateCode, MultipartFile file, int limit) {
|
||||
UploadTemplateVO template = loadTemplate(templateCode);
|
||||
List<UploadTemplateColumnVO> columns = columnMapper.selectByTemplateId(template.getTemplateId());
|
||||
|
||||
lookupCache.preload(columns);
|
||||
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
int dataStartRow = template.getDataStartRow() != null ? template.getDataStartRow() : 2;
|
||||
int sheetIndex = template.getSheetIndex() != null ? template.getSheetIndex() : 0;
|
||||
|
||||
try (InputStream is = file.getInputStream();
|
||||
Workbook wb = StreamingReader.builder()
|
||||
.rowCacheSize(100)
|
||||
.bufferSize(4096)
|
||||
.open(is)) {
|
||||
|
||||
Sheet sheet = wb.getSheetAt(sheetIndex);
|
||||
int rowNum = 0;
|
||||
|
||||
for (Row row : sheet) {
|
||||
rowNum++;
|
||||
if (rowNum < dataStartRow) continue;
|
||||
if ("Y".equalsIgnoreCase(template.getSkipEmptyRow()) && isEmptyRow(row)) continue;
|
||||
|
||||
try {
|
||||
result.add(transformEngine.transform(row, columns));
|
||||
} catch (Exception e) {
|
||||
result.add(Map.of("_error", "행 " + rowNum + ": " + e.getMessage()));
|
||||
}
|
||||
|
||||
if (result.size() >= limit) break;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Preview error: templateCode={}", templateCode, e);
|
||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 내부 헬퍼
|
||||
// ============================================================
|
||||
|
||||
private UploadTemplateVO loadTemplate(String templateCode) {
|
||||
UploadTemplateVO template = templateMapper.selectByCode(templateCode);
|
||||
if (template == null) {
|
||||
throw new BizException(ErrorCode.NOT_FOUND, "업로드 템플릿을 찾을 수 없습니다: " + templateCode);
|
||||
}
|
||||
if (!"Y".equalsIgnoreCase(template.getIsActive())) {
|
||||
throw new BizException(ErrorCode.INVALID_STATUS, "비활성 템플릿입니다: " + templateCode);
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
private void validateTargetTable(String targetTable) {
|
||||
if (targetTable == null || !ALLOWED_TARGET_TABLES.contains(targetTable)) {
|
||||
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
||||
"허용되지 않는 대상 테이블: " + targetTable);
|
||||
}
|
||||
}
|
||||
|
||||
private UploadHistoryVO initHistory(UploadTemplateVO template, MultipartFile file) {
|
||||
UploadHistoryVO h = new UploadHistoryVO();
|
||||
h.setTemplateId(template.getTemplateId());
|
||||
h.setTemplateCode(template.getTemplateCode());
|
||||
h.setFileName(file.getOriginalFilename());
|
||||
h.setFileSize(file.getSize());
|
||||
h.setStatus("PROCESSING");
|
||||
h.setStartedAt(LocalDateTime.now());
|
||||
h.setCreatedBy(SecurityUtil.getCurrentUserId());
|
||||
return h;
|
||||
}
|
||||
|
||||
private void updateHistoryFail(Long historyId, String message) {
|
||||
historyMapper.updateStatus(historyId, "FAIL", message,
|
||||
0, 0, 0, 0, null, 0);
|
||||
}
|
||||
|
||||
private void validateRow(Map<String, Object> row, List<UploadTemplateColumnVO> columns,
|
||||
int rowNum, List<ExcelErrorRow> errors) {
|
||||
for (UploadTemplateColumnVO col : columns) {
|
||||
Object value = row.get(col.getTargetField());
|
||||
String strVal = value == null ? null : value.toString();
|
||||
|
||||
// 필수값 검증
|
||||
if ("Y".equalsIgnoreCase(col.getIsRequired()) && (strVal == null || strVal.isBlank())) {
|
||||
String msg = col.getValidationMessage() != null
|
||||
? col.getValidationMessage()
|
||||
: "[" + col.getTargetField() + "] 은(는) 필수입니다";
|
||||
errors.add(new ExcelErrorRow(rowNum, col.getTargetField(), strVal, msg));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 정규식 검증
|
||||
if (strVal != null && col.getValidationRegex() != null && !col.getValidationRegex().isBlank()) {
|
||||
if (!Pattern.matches(col.getValidationRegex(), strVal)) {
|
||||
String msg = col.getValidationMessage() != null
|
||||
? col.getValidationMessage()
|
||||
: "[" + col.getTargetField() + "] 형식이 올바르지 않습니다: " + strVal;
|
||||
errors.add(new ExcelErrorRow(rowNum, col.getTargetField(), strVal, msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isEmptyRow(Row row) {
|
||||
if (row == null) return true;
|
||||
for (org.apache.poi.ss.usermodel.Cell cell : row) {
|
||||
if (cell != null && cell.getCellType() != CellType.BLANK) {
|
||||
String v = "";
|
||||
try { v = cell.getStringCellValue(); } catch (Exception ignored) {}
|
||||
if (!v.isBlank()) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 행 목록을 엑셀로 만들어 file_storage 에 적재하고 fileId 반환.
|
||||
* 실패 시 업로드를 중단하지 않고 null 반환.
|
||||
*/
|
||||
private Long createErrorFile(List<ExcelErrorRow> errors, String originalFilename) {
|
||||
try {
|
||||
byte[] bytes = buildErrorExcelBytes(errors);
|
||||
String name = "error_" + (originalFilename != null ? originalFilename : "upload.xlsx");
|
||||
BytesMultipartFile mockFile = new BytesMultipartFile(name, bytes);
|
||||
com.ga.common.file.FileVO saved = fileService.upload(mockFile, "UPLOAD_ERROR");
|
||||
return saved.getFileId();
|
||||
} catch (Exception e) {
|
||||
log.warn("에러 파일 생성 실패 (업로드는 계속 진행)", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] buildErrorExcelBytes(List<ExcelErrorRow> errors) throws Exception {
|
||||
try (org.apache.poi.xssf.streaming.SXSSFWorkbook wb =
|
||||
new org.apache.poi.xssf.streaming.SXSSFWorkbook(100)) {
|
||||
|
||||
org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("errors");
|
||||
|
||||
org.apache.poi.ss.usermodel.Row header = sheet.createRow(0);
|
||||
header.createCell(0).setCellValue("행번호");
|
||||
header.createCell(1).setCellValue("필드");
|
||||
header.createCell(2).setCellValue("값");
|
||||
header.createCell(3).setCellValue("오류내용");
|
||||
|
||||
int rowIdx = 1;
|
||||
for (ExcelErrorRow e : errors) {
|
||||
org.apache.poi.ss.usermodel.Row row = sheet.createRow(rowIdx++);
|
||||
row.createCell(0).setCellValue(e.getRowNumber());
|
||||
row.createCell(1).setCellValue(e.getFieldName() != null ? e.getFieldName() : "");
|
||||
row.createCell(2).setCellValue(e.getValue() != null ? e.getValue() : "");
|
||||
row.createCell(3).setCellValue(e.getErrorMessage() != null ? e.getErrorMessage() : "");
|
||||
}
|
||||
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
wb.write(baos);
|
||||
wb.dispose();
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// byte[] → MultipartFile 래퍼 (MockMultipartFile 의존성 제거)
|
||||
// ============================================================
|
||||
|
||||
private static class BytesMultipartFile implements MultipartFile {
|
||||
private final String filename;
|
||||
private final byte[] content;
|
||||
|
||||
BytesMultipartFile(String filename, byte[] content) {
|
||||
this.filename = filename;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
@Override @NonNull public String getName() { return "file"; }
|
||||
@Override public String getOriginalFilename() { return filename; }
|
||||
@Override public String getContentType() {
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
}
|
||||
@Override public boolean isEmpty() { return content == null || content.length == 0; }
|
||||
@Override public long getSize() { return content == null ? 0 : content.length; }
|
||||
@Override @NonNull public byte[] getBytes() { return content == null ? new byte[0] : content; }
|
||||
@Override @NonNull public java.io.InputStream getInputStream() {
|
||||
return new java.io.ByteArrayInputStream(content == null ? new byte[0] : content);
|
||||
}
|
||||
@Override public void transferTo(@NonNull java.io.File dest) throws java.io.IOException {
|
||||
try (java.io.FileOutputStream fos = new java.io.FileOutputStream(dest)) {
|
||||
fos.write(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user