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:
GA Pro
2026-05-10 23:10:32 +09:00
parent 4e008527b9
commit a314a95ecf
29 changed files with 2991 additions and 0 deletions
@@ -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);
}
}
}
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.api.service.upload.DynamicInsertMapper">
<!--
동적 단건 INSERT.
targetTable 은 UploadService 의 whitelist 검증 후 전달한다.
row Map 의 key/value 를 동적으로 조립한다.
-->
<insert id="insertRow">
INSERT INTO ${targetTable} (
<foreach collection="row" index="col" item="val" separator=",">
${col}
</foreach>
) VALUES (
<foreach collection="row" index="col" item="val" separator=",">
#{val}
</foreach>
)
</insert>
<!--
동적 배치 INSERT.
rows 첫 번째 맵의 key 순서로 컬럼명을 결정한다.
-->
<insert id="insertBatch">
INSERT INTO ${targetTable} (
<foreach collection="rows[0]" index="col" item="val" separator=",">
${col}
</foreach>
) VALUES
<foreach collection="rows" item="row" separator=",">
(
<foreach collection="row" index="col" item="val" separator=",">
#{val}
</foreach>
)
</foreach>
</insert>
</mapper>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.api.service.upload.LookupMapper">
<!--
동적 테이블/컬럼 조회.
${} 사용 — LookupCache.preload() 에서 whitelist 검증 후 호출한다.
-->
<select id="selectAll" resultType="java.util.HashMap">
SELECT ${sourceField} AS source_value,
${targetField} AS target_value
FROM ${table}
</select>
</mapper>