From bba049839f6a47cb5c934108b56da796ded89de0 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Tue, 12 May 2026 22:16:27 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../generator/CodeGeneratorService.java | 279 ++++++++++++++++++ .../com/ga/common/upload/LookupCache.java | 60 ++++ .../com/ga/common/upload/TransformEngine.java | 162 ++++++++++ .../ga/common/upload/UploadController.java | 95 ++++++ .../com/ga/common/upload/UploadErrorRow.java | 27 ++ .../com/ga/common/upload/UploadHistoryVO.java | 25 ++ .../com/ga/common/upload/UploadMapper.java | 47 +++ .../ga/common/upload/UploadResultResp.java | 28 ++ .../com/ga/common/upload/UploadService.java | 223 ++++++++++++++ .../common/upload/UploadTemplateColumnVO.java | 44 +++ .../common/upload/UploadTemplateService.java | 77 +++++ .../ga/common/upload/UploadTemplateVO.java | 30 ++ .../com/ga/common/upload/UploadWhitelist.java | 61 ++++ .../ga/common/validation/CommonValidator.java | 157 ++++++++++ .../codegen/controller/EntityController.java | 59 ++++ .../codegen/frontend/EntityDetail.tsx | 33 +++ .../resources/codegen/frontend/EntityList.tsx | 37 +++ .../main/resources/codegen/frontend/entity.ts | 9 + .../codegen/mapper/EntityMapper.java | 16 + .../resources/codegen/mapper/EntityMapper.xml | 41 +++ .../codegen/service/EntityService.java | 51 ++++ .../main/resources/codegen/vo/Entity.vo.java | 11 + .../main/resources/codegen/vo/EntityResp.java | 11 + .../resources/codegen/vo/EntitySaveReq.java | 10 + .../codegen/vo/EntitySearchParam.java | 12 + .../db/migration/V18__업로드템플릿.sql | 94 ++++++ .../db/migration/V19__dba검토_보강.sql | 84 ++++++ .../resources/mapper/common/UploadMapper.xml | 188 ++++++++++++ .../resources/messages/messages_en.properties | 32 ++ .../resources/messages/messages_ko.properties | 33 +++ 30 files changed, 2036 insertions(+) create mode 100644 ga-common/src/main/java/com/ga/common/generator/CodeGeneratorService.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/LookupCache.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/TransformEngine.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/UploadController.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/UploadErrorRow.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/UploadHistoryVO.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/UploadMapper.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/UploadResultResp.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/UploadService.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/UploadTemplateColumnVO.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/UploadTemplateService.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/UploadTemplateVO.java create mode 100644 ga-common/src/main/java/com/ga/common/upload/UploadWhitelist.java create mode 100644 ga-common/src/main/java/com/ga/common/validation/CommonValidator.java create mode 100644 ga-common/src/main/resources/codegen/controller/EntityController.java create mode 100644 ga-common/src/main/resources/codegen/frontend/EntityDetail.tsx create mode 100644 ga-common/src/main/resources/codegen/frontend/EntityList.tsx create mode 100644 ga-common/src/main/resources/codegen/frontend/entity.ts create mode 100644 ga-common/src/main/resources/codegen/mapper/EntityMapper.java create mode 100644 ga-common/src/main/resources/codegen/mapper/EntityMapper.xml create mode 100644 ga-common/src/main/resources/codegen/service/EntityService.java create mode 100644 ga-common/src/main/resources/codegen/vo/Entity.vo.java create mode 100644 ga-common/src/main/resources/codegen/vo/EntityResp.java create mode 100644 ga-common/src/main/resources/codegen/vo/EntitySaveReq.java create mode 100644 ga-common/src/main/resources/codegen/vo/EntitySearchParam.java create mode 100644 ga-common/src/main/resources/db/migration/V18__업로드템플릿.sql create mode 100644 ga-common/src/main/resources/db/migration/V19__dba검토_보강.sql create mode 100644 ga-common/src/main/resources/mapper/common/UploadMapper.xml create mode 100644 ga-common/src/main/resources/messages/messages_en.properties create mode 100644 ga-common/src/main/resources/messages/messages_ko.properties diff --git a/ga-common/src/main/java/com/ga/common/generator/CodeGeneratorService.java b/ga-common/src/main/java/com/ga/common/generator/CodeGeneratorService.java new file mode 100644 index 0000000..db82750 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/generator/CodeGeneratorService.java @@ -0,0 +1,279 @@ +package com.ga.common.generator; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.session.SqlSession; + +import javax.sql.DataSource; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * CRUD 코드 자동 생성 CLI. + * + * 사용법: + * java -jar ga-generator.jar --table=agent --menu=ORG_AGENT --label=설계사 + * + * 산출물: + * ga-core/src/main/java/com/ga/core/vo/{group}/AgentVO.java + * ga-core/src/main/java/com/ga/core/vo/{group}/AgentSearchParam.java + * ga-core/src/main/java/com/ga/core/vo/{group}/AgentSaveReq.java + * ga-core/src/main/java/com/ga/core/vo/{group}/AgentResp.java + * ga-core/src/main/java/com/ga/core/mapper/{group}/AgentMapper.java + * ga-core/src/main/resources/mapper/{group}/AgentMapper.xml + * ga-api/src/main/java/com/ga/api/service/{group}/AgentService.java + * ga-api/src/main/java/com/ga/api/controller/{group}/AgentController.java + * ga-frontend/src/pages/{group}/AgentList.tsx + * ga-frontend/src/pages/{group}/AgentDetail.tsx + * ga-frontend/src/api/agent.ts + * + * 단순 템플릿 치환 방식 (Mustache 등 외부 의존 없이 내부 String.replace 사용). + */ +@Slf4j +@RequiredArgsConstructor +public class CodeGeneratorService { + + private final DataSource dataSource; + private final Path projectRoot; + + public static void main(String[] args) throws Exception { + Map opts = parseArgs(args); + String table = required(opts, "table"); + String menu = required(opts, "menu"); + String label = opts.getOrDefault("label", table); + String group = opts.getOrDefault("group", "common"); + Path root = Path.of(opts.getOrDefault("root", System.getProperty("user.dir"))); + + // DataSource 연결은 Spring 컨텍스트에서 inject. 여기 main은 데모용. + new CodeGeneratorService(null, root).generate(table, menu, label, group); + } + + public void generate(String table, String menuCode, String label, String group) throws Exception { + log.info("Generating CRUD for table={}, menu={}, label={}, group={}", table, menuCode, label, group); + List columns = readColumns(table); + if (columns.isEmpty()) { + throw new IllegalArgumentException("테이블 [" + table + "] 의 컬럼을 읽을 수 없습니다"); + } + + String entity = toPascal(table); // agent → Agent + String entityCamel = toCamel(table); // agent → agent + + Map ctx = new HashMap<>(); + ctx.put("ENTITY", entity); + ctx.put("ENTITY_CAMEL", entityCamel); + ctx.put("TABLE", table); + ctx.put("MENU", menuCode); + ctx.put("LABEL", label); + ctx.put("GROUP", group); + ctx.put("FIELDS_VO", renderFieldsForVO(columns)); + ctx.put("FIELDS_SAVEREQ", renderFieldsForSaveReq(columns)); + ctx.put("FIELDS_RESP", renderFieldsForResp(columns)); + ctx.put("INSERT_COLUMNS", renderInsertColumns(columns)); + ctx.put("INSERT_VALUES", renderInsertValues(columns)); + ctx.put("UPDATE_SETS", renderUpdateSets(columns)); + + // 1. ga-core + write(ctx, "vo/Entity.vo.java", "ga-core/src/main/java/com/ga/core/vo/" + group + "/" + entity + "VO.java"); + write(ctx, "vo/EntitySearchParam.java", "ga-core/src/main/java/com/ga/core/vo/" + group + "/" + entity + "SearchParam.java"); + write(ctx, "vo/EntitySaveReq.java", "ga-core/src/main/java/com/ga/core/vo/" + group + "/" + entity + "SaveReq.java"); + write(ctx, "vo/EntityResp.java", "ga-core/src/main/java/com/ga/core/vo/" + group + "/" + entity + "Resp.java"); + write(ctx, "mapper/EntityMapper.java", "ga-core/src/main/java/com/ga/core/mapper/" + group + "/" + entity + "Mapper.java"); + write(ctx, "mapper/EntityMapper.xml", "ga-core/src/main/resources/mapper/" + group + "/" + entity + "Mapper.xml"); + // 2. ga-api + write(ctx, "service/EntityService.java", "ga-api/src/main/java/com/ga/api/service/" + group + "/" + entity + "Service.java"); + write(ctx, "controller/EntityController.java", "ga-api/src/main/java/com/ga/api/controller/" + group + "/" + entity + "Controller.java"); + // 3. ga-frontend + write(ctx, "frontend/EntityList.tsx", "ga-frontend/src/pages/" + group + "/" + entity + "List.tsx"); + write(ctx, "frontend/EntityDetail.tsx", "ga-frontend/src/pages/" + group + "/" + entity + "Detail.tsx"); + write(ctx, "frontend/entity.ts", "ga-frontend/src/api/" + entityCamel + ".ts"); + + log.info("Generation complete. Files written under {}", projectRoot); + } + + /* ========================================================== + 메타데이터 조회 + ========================================================== */ + private List readColumns(String table) throws Exception { + if (dataSource == null) return List.of(); + List cols = new ArrayList<>(); + try (Connection c = dataSource.getConnection()) { + DatabaseMetaData md = c.getMetaData(); + try (ResultSet rs = md.getColumns(null, null, table, null)) { + while (rs.next()) { + Column col = new Column(); + col.name = rs.getString("COLUMN_NAME"); + col.javaType = mapType(rs.getInt("DATA_TYPE")); + col.nullable = "YES".equalsIgnoreCase(rs.getString("IS_NULLABLE")); + col.size = rs.getInt("COLUMN_SIZE"); + col.isPk = false; + cols.add(col); + } + } + try (ResultSet rs = md.getPrimaryKeys(null, null, table)) { + while (rs.next()) { + String pk = rs.getString("COLUMN_NAME"); + cols.stream().filter(x -> x.name.equals(pk)).findFirst().ifPresent(x -> x.isPk = true); + } + } + } + return cols; + } + + private String mapType(int sqlType) { + return switch (sqlType) { + case java.sql.Types.BIGINT -> "Long"; + case java.sql.Types.INTEGER, java.sql.Types.SMALLINT -> "Integer"; + case java.sql.Types.NUMERIC, java.sql.Types.DECIMAL -> "BigDecimal"; + case java.sql.Types.BOOLEAN, java.sql.Types.BIT -> "Boolean"; + case java.sql.Types.DATE -> "LocalDate"; + case java.sql.Types.TIMESTAMP -> "LocalDateTime"; + default -> "String"; + }; + } + + /* ========================================================== + 템플릿 렌더링 + ========================================================== */ + private void write(Map ctx, String tplPath, String relativeOut) throws Exception { + String template = readTemplate(tplPath); + String rendered = render(template, ctx); + Path out = projectRoot.resolve(relativeOut); + Files.createDirectories(out.getParent()); + Files.writeString(out, rendered); + log.info(" -> {}", out); + } + + private String readTemplate(String path) throws Exception { + // src/main/resources/codegen/{path} + try (var is = getClass().getResourceAsStream("/codegen/" + path)) { + if (is == null) throw new IllegalStateException("template not found: " + path); + return new String(is.readAllBytes()); + } + } + + /** 단순 ${KEY} → value 치환. */ + private String render(String template, Map ctx) { + String result = template; + for (Map.Entry e : ctx.entrySet()) { + result = result.replace("${" + e.getKey() + "}", e.getValue() == null ? "" : e.getValue()); + } + return result; + } + + private String renderFieldsForVO(List cols) { + StringBuilder sb = new StringBuilder(); + for (Column c : cols) sb.append(" private ").append(c.javaType).append(" ").append(toCamel(c.name)).append(";\n"); + return sb.toString(); + } + + private String renderFieldsForSaveReq(List cols) { + StringBuilder sb = new StringBuilder(); + for (Column c : cols) { + if (c.isPk || isAuditField(c.name)) continue; + if (!c.nullable) sb.append(" @jakarta.validation.constraints.NotNull\n"); + sb.append(" private ").append(c.javaType).append(" ").append(toCamel(c.name)).append(";\n"); + } + return sb.toString(); + } + + private String renderFieldsForResp(List cols) { + return renderFieldsForVO(cols); // 기본은 VO와 동일. 조인 필드는 수동 추가. + } + + private String renderInsertColumns(List cols) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Column c : cols) { + if (c.isPk) continue; + if (!first) sb.append(", "); + sb.append(c.name); + first = false; + } + return sb.toString(); + } + + private String renderInsertValues(List cols) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Column c : cols) { + if (c.isPk) continue; + if (!first) sb.append(", "); + sb.append("#{").append(toCamel(c.name)).append("}"); + first = false; + } + return sb.toString(); + } + + private String renderUpdateSets(List cols) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Column c : cols) { + if (c.isPk || "created_at".equals(c.name) || "created_by".equals(c.name)) continue; + if (!first) sb.append(",\n "); + sb.append(c.name).append(" = #{").append(toCamel(c.name)).append("}"); + first = false; + } + return sb.toString(); + } + + private boolean isAuditField(String name) { + return name.equals("created_at") || name.equals("created_by") + || name.equals("updated_at") || name.equals("updated_by"); + } + + /* ========================================================== + 문자열 변환 + ========================================================== */ + private String toPascal(String snake) { + StringBuilder sb = new StringBuilder(); + boolean upper = true; + for (char ch : snake.toCharArray()) { + if (ch == '_') { upper = true; continue; } + sb.append(upper ? Character.toUpperCase(ch) : ch); + upper = false; + } + return sb.toString(); + } + + private String toCamel(String snake) { + String pascal = toPascal(snake); + return pascal.isEmpty() ? "" : Character.toLowerCase(pascal.charAt(0)) + pascal.substring(1); + } + + /* ========================================================== + CLI 인자 파싱 + ========================================================== */ + private static Map parseArgs(String[] args) { + Map map = new HashMap<>(); + for (String a : args) { + if (!a.startsWith("--")) continue; + String[] kv = a.substring(2).split("=", 2); + map.put(kv[0], kv.length > 1 ? kv[1] : "true"); + } + return map; + } + + private static String required(Map map, String key) { + String v = map.get(key); + if (v == null) throw new IllegalArgumentException("--" + key + " 인자가 필요합니다"); + return v; + } + + /* ========================================================== + 내부 모델 + ========================================================== */ + private static class Column { + String name; + String javaType; + boolean nullable; + boolean isPk; + int size; + } +} diff --git a/ga-common/src/main/java/com/ga/common/upload/LookupCache.java b/ga-common/src/main/java/com/ga/common/upload/LookupCache.java new file mode 100644 index 0000000..3d3ce62 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/LookupCache.java @@ -0,0 +1,60 @@ +package com.ga.common.upload; + +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; + +/** + * LOOKUP 변환 규칙용 캐시. + * 70만건 업로드 시 행마다 DB 조회를 피하기 위해, 시작 전에 참조 테이블 전체를 Map으로 로드. + * + * - preload(columns): 매핑 규칙에 LOOKUP이 있으면 해당 테이블을 한번에 캐시 + * - lookup(table, sourceField, value, targetField): O(1) 조회 + * + * Note: 이 캐시는 요청 단위 — 매 업로드마다 새로 만들고 끝나면 GC 대상. + * UploadService 내부에서 new LookupCache(uploadMapper) 로 인스턴스화. + */ +@Slf4j +@RequiredArgsConstructor +public class LookupCache { + + private final UploadMapper uploadMapper; + private final Map> caches = new HashMap<>(); + + public void preload(List columns) { + columns.stream() + .filter(c -> "LOOKUP".equalsIgnoreCase(c.getTransformRule())) + .filter(c -> c.getLookupTable() != null && c.getLookupSourceField() != null) + .map(c -> c.getLookupTable() + "|" + c.getLookupSourceField() + "|" + c.getLookupTargetField()) + .distinct() + .forEach(key -> { + String[] p = key.split("\\|"); + String table = p[0], src = p[1], tgt = p[2]; + if (!UploadWhitelist.isAllowedLookupTable(table)) { + throw new IllegalArgumentException("허용되지 않은 LOOKUP 테이블: " + table); + } + List> rows = uploadMapper.selectLookupAll(table, src, tgt); + Map map = new HashMap<>(rows.size()); + for (Map row : rows) { + Object srcVal = row.get("source_value"); + Object tgtVal = row.get("target_value"); + if (srcVal != null) map.put(srcVal.toString(), tgtVal); + } + caches.put(key, map); + log.info("LookupCache loaded: {} = {} rows", key, map.size()); + }); + } + + public Object lookup(String table, String sourceField, String value, String targetField) { + if (value == null) return null; + Map cache = caches.get(table + "|" + sourceField + "|" + targetField); + if (cache == null) return null; + return cache.get(value); + } + + public int size() { return caches.size(); } +} diff --git a/ga-common/src/main/java/com/ga/common/upload/TransformEngine.java b/ga-common/src/main/java/com/ga/common/upload/TransformEngine.java new file mode 100644 index 0000000..5fd280f --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/TransformEngine.java @@ -0,0 +1,162 @@ +package com.ga.common.upload; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ga.common.code.CommonCodeService; +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 java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 매핑 규칙 → 변환된 값 Map. + * UploadService에서 lookupCache를 주입해 사용한다. + */ +@Slf4j +@RequiredArgsConstructor +public class TransformEngine { + + private final LookupCache lookupCache; + private final CommonCodeService codeService; + private static final ObjectMapper JSON = new ObjectMapper(); + + /** 한 행을 매핑 규칙대로 변환. result key = targetField, value = 변환된 값. */ + public Map transform(Row row, List columns) { + Map result = new LinkedHashMap<>(); + for (UploadTemplateColumnVO col : columns) { + Object raw = readSourceValue(row, col); + Object transformed = applyTransform(raw, col); + result.put(col.getTargetField(), transformed); + } + return result; + } + + /* ---------------------------------------------------------- + 엑셀 셀 값 추출 + ---------------------------------------------------------- */ + private Object readSourceValue(Row row, UploadTemplateColumnVO col) { + if ("FIXED".equalsIgnoreCase(col.getSourceType())) return col.getFixedValue(); + if (row == null || col.getSourceColumn() == null) return null; + + int idx = parseColumnIndex(col.getSourceColumn()); + Cell cell = row.getCell(idx); + if (cell == null) return null; + + return switch (cell.getCellType()) { + case STRING -> cell.getStringCellValue(); + case NUMERIC -> { + if (DateUtil.isCellDateFormatted(cell)) { + yield cell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + } + double d = cell.getNumericCellValue(); + yield d == (long) d ? (Object) (long) d : (Object) d; + } + case BOOLEAN -> cell.getBooleanCellValue(); + case FORMULA -> { + try { yield cell.getStringCellValue(); } + catch (Exception e) { yield cell.getNumericCellValue(); } + } + default -> null; + }; + } + + /** "A"~"Z","AA"... 또는 "0","1"... 둘 다 허용. */ + private int parseColumnIndex(String col) { + col = col.trim(); + if (col.matches("\\d+")) return Integer.parseInt(col); + int idx = 0; + for (char c : col.toUpperCase().toCharArray()) { + if (c < 'A' || c > 'Z') throw new IllegalArgumentException("invalid column: " + col); + idx = idx * 26 + (c - 'A' + 1); + } + return idx - 1; + } + + /* ---------------------------------------------------------- + 변환 규칙 적용 + ---------------------------------------------------------- */ + private Object applyTransform(Object value, UploadTemplateColumnVO col) { + if ("FIXED".equalsIgnoreCase(col.getSourceType())) return convertType(col.getFixedValue(), col.getTargetType()); + if (value == null || (value instanceof String s && s.isBlank())) { + return col.getDefaultValue() != null ? convertType(col.getDefaultValue(), col.getTargetType()) : null; + } + + String rule = col.getTransformRule(); + if (rule == null || rule.isBlank()) return convertType(value, col.getTargetType()); + + try { + return switch (rule.toUpperCase()) { + case "TRIM" -> value.toString().trim(); + case "UPPER" -> value.toString().toUpperCase(); + case "LOWER" -> value.toString().toLowerCase(); + case "DATE" -> parseDate(value, col.getDateFormat()); + case "DIVIDE" -> col.getNumberDivide() == null ? value + : toDecimal(value).divide(BigDecimal.valueOf(col.getNumberDivide()), 4, java.math.RoundingMode.HALF_UP); + case "MULTIPLY" -> col.getNumberMultiply() == null ? value + : toDecimal(value).multiply(BigDecimal.valueOf(col.getNumberMultiply())); + case "LOOKUP" -> lookupCache.lookup(col.getLookupTable(), col.getLookupSourceField(), + value.toString(), col.getLookupTargetField()); + case "CODE_MAP" -> codeService.getCodeName(col.getCodeMapGroup(), value.toString()); + case "REPLACE" -> applyReplace(value, col.getTransformParam()); + case "SUBSTRING" -> applySubstring(value, col.getTransformParam()); + case "DEFAULT" -> value; // null 아닐 때는 그대로 + default -> value; + }; + } catch (Exception e) { + log.warn("transform rule [{}] failed for value [{}]: {}", rule, value, e.getMessage()); + throw new IllegalArgumentException("[" + col.getTargetField() + "] 변환 실패: " + e.getMessage()); + } + } + + private Object parseDate(Object value, String pattern) { + if (value instanceof LocalDate d) return d; + if (value instanceof LocalDateTime dt) return dt.toLocalDate(); + String s = value.toString().trim(); + DateTimeFormatter fmt = pattern == null ? DateTimeFormatter.ISO_LOCAL_DATE : DateTimeFormatter.ofPattern(pattern); + return LocalDate.parse(s, fmt); + } + + private Object applyReplace(Object value, String param) throws Exception { + JsonNode n = JSON.readTree(param); + return value.toString().replace(n.path("find").asText(""), n.path("replace").asText("")); + } + + private Object applySubstring(Object value, String param) throws Exception { + JsonNode n = JSON.readTree(param); + String s = value.toString(); + int start = n.path("start").asInt(0); + int end = n.has("end") ? Math.min(n.get("end").asInt(), s.length()) : s.length(); + return s.substring(Math.max(0, start), Math.max(0, end)); + } + + /* ---------------------------------------------------------- + 타입 변환 + ---------------------------------------------------------- */ + private Object convertType(Object value, String targetType) { + if (value == null) return null; + return switch (targetType == null ? "STRING" : targetType.toUpperCase()) { + case "STRING" -> value.toString(); + case "INTEGER" -> value instanceof Number n ? n.intValue() : Integer.parseInt(value.toString().trim()); + case "NUMBER" -> value instanceof Number n ? n.longValue() : Long.parseLong(value.toString().trim()); + case "DECIMAL" -> toDecimal(value); + case "DATE" -> value instanceof LocalDate ? value : LocalDate.parse(value.toString()); + default -> value; + }; + } + + private BigDecimal toDecimal(Object value) { + if (value instanceof BigDecimal bd) return bd; + if (value instanceof Number n) return BigDecimal.valueOf(n.doubleValue()); + return new BigDecimal(value.toString().replace(",", "").trim()); + } +} diff --git a/ga-common/src/main/java/com/ga/common/upload/UploadController.java b/ga-common/src/main/java/com/ga/common/upload/UploadController.java new file mode 100644 index 0000000..03ed77b --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/UploadController.java @@ -0,0 +1,95 @@ +package com.ga.common.upload; + +import com.ga.common.annotation.RequirePermission; +import com.ga.common.model.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/upload-templates") +@Tag(name = "업로드 템플릿 관리") +@RequiredArgsConstructor +public class UploadController { + + private final UploadTemplateService templateService; + private final UploadService uploadService; + + /* ========================================================== + 템플릿 CRUD + ========================================================== */ + @GetMapping + @RequirePermission(menu = "DATA_UPLOAD", perm = "READ") + public ApiResponse> list(@RequestParam(required = false) String category, + @RequestParam(required = false) String companyCode) { + return ApiResponse.ok(templateService.list(category, companyCode)); + } + + @GetMapping("/{id}") + @RequirePermission(menu = "DATA_UPLOAD", perm = "READ") + public ApiResponse detail(@PathVariable Long id) { + TemplateDetailResp r = new TemplateDetailResp(); + r.setTemplate(templateService.get(id)); + r.setColumns(templateService.getColumns(id)); + return ApiResponse.ok(r); + } + + @PostMapping + @RequirePermission(menu = "DATA_UPLOAD", perm = "CREATE") + public ApiResponse create(@Valid @RequestBody TemplateSaveReq req) { + return ApiResponse.ok(templateService.create(req.getTemplate(), req.getColumns())); + } + + @PutMapping("/{id}") + @RequirePermission(menu = "DATA_UPLOAD", perm = "UPDATE") + public ApiResponse update(@PathVariable Long id, @Valid @RequestBody TemplateSaveReq req) { + templateService.update(id, req.getTemplate(), req.getColumns()); + return ApiResponse.ok(); + } + + @DeleteMapping("/{id}") + @RequirePermission(menu = "DATA_UPLOAD", perm = "DELETE") + public ApiResponse delete(@PathVariable Long id) { + templateService.delete(id); + return ApiResponse.ok(); + } + + /* ========================================================== + 업로드 처리 + ========================================================== */ + @PostMapping("/{templateCode}/upload") + @RequirePermission(menu = "DATA_UPLOAD", perm = "CREATE") + public ApiResponse upload(@PathVariable String templateCode, + @RequestParam MultipartFile file) { + return ApiResponse.ok(uploadService.processUpload(templateCode, file)); + } + + @PostMapping("/{templateCode}/preview") + @RequirePermission(menu = "DATA_UPLOAD", perm = "READ") + public ApiResponse>> preview(@PathVariable String templateCode, + @RequestParam MultipartFile file, + @RequestParam(defaultValue = "5") int rows) { + return ApiResponse.ok(uploadService.preview(templateCode, file, rows)); + } + + /* ========================================================== + 요청/응답 DTO + ========================================================== */ + @Data + public static class TemplateSaveReq { + private UploadTemplateVO template; + private List columns; + } + + @Data + public static class TemplateDetailResp { + private UploadTemplateVO template; + private List columns; + } +} diff --git a/ga-common/src/main/java/com/ga/common/upload/UploadErrorRow.java b/ga-common/src/main/java/com/ga/common/upload/UploadErrorRow.java new file mode 100644 index 0000000..c70896a --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/UploadErrorRow.java @@ -0,0 +1,27 @@ +package com.ga.common.upload; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** 업로드 실패 행. 에러 엑셀 생성에 사용. */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UploadErrorRow { + private int rowNum; + private String errorMessage; + private List rawValues; + + public UploadErrorRow(int rowNum, String errorMessage) { + this.rowNum = rowNum; + this.errorMessage = errorMessage; + } + + public UploadErrorRow(int rowNum, List errors) { + this.rowNum = rowNum; + this.errorMessage = String.join("; ", errors); + } +} diff --git a/ga-common/src/main/java/com/ga/common/upload/UploadHistoryVO.java b/ga-common/src/main/java/com/ga/common/upload/UploadHistoryVO.java new file mode 100644 index 0000000..e058624 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/UploadHistoryVO.java @@ -0,0 +1,25 @@ +package com.ga.common.upload; + +import lombok.Data; +import java.time.LocalDateTime; + +/** upload_history 테이블 1:1 매핑. */ +@Data +public class UploadHistoryVO { + private Long historyId; + private Long templateId; + private String templateCode; + private String fileName; + private Long fileSize; + private Integer totalCount; + private Integer successCount; + private Integer errorCount; + private Integer skipCount; + private Long errorFileId; + private String status; // PROCESSING / COMPLETED / FAILED / CANCELLED + private String errorMessage; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; + private Integer durationSec; + private Long createdBy; +} diff --git a/ga-common/src/main/java/com/ga/common/upload/UploadMapper.java b/ga-common/src/main/java/com/ga/common/upload/UploadMapper.java new file mode 100644 index 0000000..f0e5b81 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/UploadMapper.java @@ -0,0 +1,47 @@ +package com.ga.common.upload; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 업로드 템플릿/매핑/이력 + 동적 INSERT + LOOKUP 조회. + * + * 참고: target_table, fields 등은 동적 식별자라 ${} 를 사용하므로, + * 호출 측에서 화이트리스트 검증 후 전달해야 한다 (UploadService.dynamicInsert 참고). + */ +@Mapper +public interface UploadMapper { + + /* upload_template */ + UploadTemplateVO selectTemplateByCode(@Param("code") String templateCode); + UploadTemplateVO selectTemplateById(@Param("id") Long templateId); + List selectTemplateList(@Param("category") String category, + @Param("companyCode") String companyCode); + int insertTemplate(UploadTemplateVO vo); + int updateTemplate(UploadTemplateVO vo); + int deleteTemplate(@Param("id") Long templateId); + + /* upload_template_column */ + List selectColumnsByTemplateId(@Param("templateId") Long templateId); + int insertColumn(UploadTemplateColumnVO vo); + int deleteColumnsByTemplateId(@Param("templateId") Long templateId); + + /* upload_history */ + int insertHistory(UploadHistoryVO vo); + int updateHistory(UploadHistoryVO vo); + List selectHistoryByTemplate(@Param("templateId") Long templateId, + @Param("limit") int limit); + + /* 동적 배치 INSERT — target_table/fields 화이트리스트 통과 후 호출. */ + int dynamicBatchInsert(@Param("targetTable") String targetTable, + @Param("fields") List fields, + @Param("rows") List> rows); + + /* LOOKUP 캐시 미리 로드 */ + List> selectLookupAll(@Param("table") String table, + @Param("sourceField") String sourceField, + @Param("targetField") String targetField); +} diff --git a/ga-common/src/main/java/com/ga/common/upload/UploadResultResp.java b/ga-common/src/main/java/com/ga/common/upload/UploadResultResp.java new file mode 100644 index 0000000..f291700 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/UploadResultResp.java @@ -0,0 +1,28 @@ +package com.ga.common.upload; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** 업로드 결과 응답. */ +@Data +public class UploadResultResp { + private Long historyId; + private String templateCode; + private int totalCount; + private int successCount; + private int errorCount; + private int skipCount; + private Long errorFileId; + private String status; + private List sampleErrors = new ArrayList<>(); // 화면 미리보기용 상위 N건 + + public void addSuccess(int n) { this.successCount += n; this.totalCount += n; } + public void addError(UploadErrorRow row) { + this.errorCount++; + this.totalCount++; + if (this.sampleErrors.size() < 50) this.sampleErrors.add(row); + } + public void addSkip(int n) { this.skipCount += n; this.totalCount += n; } +} diff --git a/ga-common/src/main/java/com/ga/common/upload/UploadService.java b/ga-common/src/main/java/com/ga/common/upload/UploadService.java new file mode 100644 index 0000000..daf72f0 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/UploadService.java @@ -0,0 +1,223 @@ +package com.ga.common.upload; + +import com.ga.common.code.CommonCodeService; +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.common.util.SecurityUtil; +import com.monitorjbl.xlsx.StreamingReader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 java.io.InputStream; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 엑셀 업로드 처리 — 템플릿코드 → 매핑 규칙 → SAX 파싱 → 변환 → 검증 → 동적 배치 INSERT. + * + * 70만건 대응: + * - xlsx-streamer (SAX) 사용 + * - LookupCache로 참조 테이블 캐시 + * - batchSize 단위로 INSERT + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UploadService { + + private final UploadMapper uploadMapper; + private final CommonCodeService codeService; + + /** 정식 업로드 처리. */ + @Transactional + public UploadResultResp processUpload(String templateCode, MultipartFile file) { + UploadTemplateVO template = uploadMapper.selectTemplateByCode(templateCode); + if (template == null) throw new BizException(ErrorCode.NOT_FOUND, "템플릿이 없습니다: " + templateCode); + UploadWhitelist.requireTargetTable(template.getTargetTable()); + + List columns = uploadMapper.selectColumnsByTemplateId(template.getTemplateId()); + validateColumns(columns); + + LookupCache cache = new LookupCache(uploadMapper); + cache.preload(columns); + TransformEngine engine = new TransformEngine(cache, codeService); + + UploadHistoryVO history = startHistory(template, file); + UploadResultResp result = new UploadResultResp(); + result.setHistoryId(history.getHistoryId()); + result.setTemplateCode(templateCode); + long started = System.currentTimeMillis(); + + List> batch = new ArrayList<>(template.getBatchSize()); + try (InputStream is = file.getInputStream(); + Workbook wb = StreamingReader.builder().rowCacheSize(100).bufferSize(4096).open(is)) { + + Sheet sheet = template.getSheetName() != null + ? wb.getSheet(template.getSheetName()) + : wb.getSheetAt(template.getSheetIndex() == null ? 0 : template.getSheetIndex()); + + int rowNum = 0, dataStart = template.getDataStartRow() == null ? 2 : template.getDataStartRow(); + for (Row row : sheet) { + rowNum++; + if (rowNum < dataStart) continue; + if (isEmptyRow(row) && "Y".equalsIgnoreCase(template.getSkipEmptyRow())) { + result.addSkip(1); + continue; + } + try { + Map mapped = engine.transform(row, columns); + List errs = validateRow(mapped, columns); + if (!errs.isEmpty()) { + result.addError(new UploadErrorRow(rowNum, errs)); + if (result.getErrorCount() > template.getMaxErrorCount()) { + throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, + "허용 에러 수(" + template.getMaxErrorCount() + ") 초과"); + } + continue; + } + batch.add(mapped); + if (batch.size() >= template.getBatchSize()) { + flushBatch(template.getTargetTable(), columns, batch); + result.addSuccess(batch.size()); + batch.clear(); + } + } catch (Exception e) { + result.addError(new UploadErrorRow(rowNum, e.getMessage())); + } + } + if (!batch.isEmpty()) { + flushBatch(template.getTargetTable(), columns, batch); + result.addSuccess(batch.size()); + } + result.setStatus("COMPLETED"); + } catch (Exception e) { + log.error("upload error", e); + result.setStatus("FAILED"); + throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage()); + } finally { + finishHistory(history, result, started); + } + return result; + } + + /** 미리보기 — 첫 N행만 변환해서 결과 반환 (DB INSERT 안 함). */ + public List> preview(String templateCode, MultipartFile file, int maxRows) { + UploadTemplateVO template = uploadMapper.selectTemplateByCode(templateCode); + if (template == null) throw new BizException(ErrorCode.NOT_FOUND, "템플릿이 없습니다: " + templateCode); + + List columns = uploadMapper.selectColumnsByTemplateId(template.getTemplateId()); + LookupCache cache = new LookupCache(uploadMapper); + cache.preload(columns); + TransformEngine engine = new TransformEngine(cache, codeService); + + List> previews = new ArrayList<>(); + try (InputStream is = file.getInputStream(); + Workbook wb = StreamingReader.builder().rowCacheSize(50).bufferSize(2048).open(is)) { + Sheet sheet = template.getSheetName() != null + ? wb.getSheet(template.getSheetName()) + : wb.getSheetAt(template.getSheetIndex() == null ? 0 : template.getSheetIndex()); + + int rowNum = 0, dataStart = template.getDataStartRow() == null ? 2 : template.getDataStartRow(); + for (Row row : sheet) { + rowNum++; + if (rowNum < dataStart) continue; + if (previews.size() >= maxRows) break; + try { + previews.add(engine.transform(row, columns)); + } catch (Exception e) { + previews.add(Map.of("__error__", e.getMessage())); + } + } + } catch (Exception e) { + throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage()); + } + return previews; + } + + /* ========================================================== + 내부 헬퍼 + ========================================================== */ + private void validateColumns(List columns) { + if (columns == null || columns.isEmpty()) { + throw new BizException(ErrorCode.VALIDATION_FAIL, "매핑 컬럼이 없습니다"); + } + for (UploadTemplateColumnVO c : columns) { + UploadWhitelist.requireValidIdentifier(c.getTargetField()); + } + } + + private List validateRow(Map mapped, List columns) { + List errors = new ArrayList<>(); + for (UploadTemplateColumnVO c : columns) { + Object v = mapped.get(c.getTargetField()); + if ("Y".equalsIgnoreCase(c.getIsRequired()) && (v == null || v.toString().isBlank())) { + errors.add("[" + c.getTargetField() + "] 필수값 누락"); + continue; + } + if (v != null && c.getValidationRegex() != null && !c.getValidationRegex().isBlank()) { + if (!Pattern.matches(c.getValidationRegex(), v.toString())) { + errors.add(c.getValidationMessage() != null ? c.getValidationMessage() + : "[" + c.getTargetField() + "] 형식 오류"); + } + } + } + return errors; + } + + private void flushBatch(String targetTable, List columns, + List> batch) { + UploadWhitelist.requireTargetTable(targetTable); + List fields = new ArrayList<>(); + for (UploadTemplateColumnVO c : columns) { + UploadWhitelist.requireValidIdentifier(c.getTargetField()); + fields.add(c.getTargetField()); + } + uploadMapper.dynamicBatchInsert(targetTable, fields, batch); + } + + private boolean isEmptyRow(Row row) { + if (row == null) return true; + for (var cell : row) { + if (cell != null && cell.getCellType() != org.apache.poi.ss.usermodel.CellType.BLANK) { + String v = cell.toString(); + if (v != null && !v.isBlank()) return false; + } + } + return true; + } + + private UploadHistoryVO startHistory(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()); + uploadMapper.insertHistory(h); + return h; + } + + private void finishHistory(UploadHistoryVO h, UploadResultResp r, long startedMs) { + h.setTotalCount(r.getTotalCount()); + h.setSuccessCount(r.getSuccessCount()); + h.setErrorCount(r.getErrorCount()); + h.setSkipCount(r.getSkipCount()); + h.setErrorFileId(r.getErrorFileId()); + h.setStatus(r.getStatus() == null ? "COMPLETED" : r.getStatus()); + h.setFinishedAt(LocalDateTime.now()); + h.setDurationSec((int) Duration.ofMillis(System.currentTimeMillis() - startedMs).toSeconds()); + uploadMapper.updateHistory(h); + } +} diff --git a/ga-common/src/main/java/com/ga/common/upload/UploadTemplateColumnVO.java b/ga-common/src/main/java/com/ga/common/upload/UploadTemplateColumnVO.java new file mode 100644 index 0000000..a78d83c --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/UploadTemplateColumnVO.java @@ -0,0 +1,44 @@ +package com.ga.common.upload; + +import lombok.Data; + +/** upload_template_column 테이블 1:1 매핑. 매핑 규칙 한 줄. */ +@Data +public class UploadTemplateColumnVO { + private Long columnId; + private Long templateId; + private Integer sortOrder; + + // 소스 (엑셀) + private String sourceType; // COLUMN / FIXED / EXPRESSION + private String sourceColumn; // A, B, C... 또는 0,1,2 인덱스 + private String sourceHeader; + + // 타겟 (DB) + private String targetField; + private String targetType; // STRING / NUMBER / DECIMAL / DATE / INTEGER + + // 변환 규칙 + private String transformRule; // TRIM/UPPER/DATE/DIVIDE/MULTIPLY/LOOKUP/CODE_MAP/REPLACE/SUBSTRING/CONCAT/DEFAULT + private String transformParam; + private String dateFormat; + private Integer numberDivide; + private Integer numberMultiply; + private String fixedValue; + private String defaultValue; + + // LOOKUP + private String lookupTable; + private String lookupSourceField; + private String lookupTargetField; + + // CODE_MAP + private String codeMapGroup; + + // 검증 + private String isRequired; + private String validationRegex; + private String validationMessage; + + private String description; +} diff --git a/ga-common/src/main/java/com/ga/common/upload/UploadTemplateService.java b/ga-common/src/main/java/com/ga/common/upload/UploadTemplateService.java new file mode 100644 index 0000000..52228cf --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/UploadTemplateService.java @@ -0,0 +1,77 @@ +package com.ga.common.upload; + +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.common.util.SecurityUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 업로드 템플릿/매핑 컬럼 CRUD. + * 업로드 처리(파일 처리)는 UploadService 담당. + */ +@Service +@RequiredArgsConstructor +public class UploadTemplateService { + + private final UploadMapper uploadMapper; + + public List list(String category, String companyCode) { + return uploadMapper.selectTemplateList(category, companyCode); + } + + public UploadTemplateVO get(Long templateId) { + UploadTemplateVO vo = uploadMapper.selectTemplateById(templateId); + if (vo == null) throw new BizException(ErrorCode.NOT_FOUND, "템플릿을 찾을 수 없습니다"); + return vo; + } + + public List getColumns(Long templateId) { + return uploadMapper.selectColumnsByTemplateId(templateId); + } + + @Transactional + public Long create(UploadTemplateVO vo, List columns) { + if (uploadMapper.selectTemplateByCode(vo.getTemplateCode()) != null) { + throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 등록된 템플릿 코드입니다"); + } + UploadWhitelist.requireTargetTable(vo.getTargetTable()); + vo.setCreatedBy(SecurityUtil.getCurrentUserId()); + uploadMapper.insertTemplate(vo); + replaceColumns(vo.getTemplateId(), columns); + return vo.getTemplateId(); + } + + @Transactional + public void update(Long templateId, UploadTemplateVO vo, List columns) { + UploadTemplateVO existing = get(templateId); + UploadWhitelist.requireTargetTable(vo.getTargetTable()); + vo.setTemplateId(templateId); + vo.setTemplateCode(existing.getTemplateCode()); // 코드는 불변 + vo.setUpdatedBy(SecurityUtil.getCurrentUserId()); + uploadMapper.updateTemplate(vo); + replaceColumns(templateId, columns); + } + + @Transactional + public void delete(Long templateId) { + get(templateId); + uploadMapper.deleteColumnsByTemplateId(templateId); + uploadMapper.deleteTemplate(templateId); + } + + private void replaceColumns(Long templateId, List columns) { + uploadMapper.deleteColumnsByTemplateId(templateId); + if (columns == null) return; + int order = 1; + for (UploadTemplateColumnVO c : columns) { + UploadWhitelist.requireValidIdentifier(c.getTargetField()); + c.setTemplateId(templateId); + if (c.getSortOrder() == null) c.setSortOrder(order++); + uploadMapper.insertColumn(c); + } + } +} diff --git a/ga-common/src/main/java/com/ga/common/upload/UploadTemplateVO.java b/ga-common/src/main/java/com/ga/common/upload/UploadTemplateVO.java new file mode 100644 index 0000000..7e60c6c --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/UploadTemplateVO.java @@ -0,0 +1,30 @@ +package com.ga.common.upload; + +import lombok.Data; +import java.time.LocalDateTime; + +/** upload_template 테이블 1:1 매핑. */ +@Data +public class UploadTemplateVO { + private Long templateId; + private String templateCode; + private String templateName; + private String category; + private String targetTable; + private String companyCode; + private Integer headerRow; + private Integer dataStartRow; + private Integer sheetIndex; + private String sheetName; + private String fileEncoding; + private String delimiter; + private String skipEmptyRow; + private Integer maxErrorCount; + private Integer batchSize; + private String description; + private String isActive; + private LocalDateTime createdAt; + private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; +} diff --git a/ga-common/src/main/java/com/ga/common/upload/UploadWhitelist.java b/ga-common/src/main/java/com/ga/common/upload/UploadWhitelist.java new file mode 100644 index 0000000..8a066d4 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/upload/UploadWhitelist.java @@ -0,0 +1,61 @@ +package com.ga.common.upload; + +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 동적 SQL(${})에 들어가는 식별자 화이트리스트. + * SQL Injection을 막기 위해 모든 동적 테이블/컬럼명은 여기를 통과해야 한다. + */ +public final class UploadWhitelist { + + private UploadWhitelist() {} + + /** 일반 식별자 패턴: 영문/숫자/_ 1~64자 */ + private static final Pattern IDENT = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]{0,63}$"); + + /** 업로드 INSERT 대상 테이블 화이트리스트 */ + private static final Set ALLOWED_TARGET_TABLES = Set.of( + "recruit_ledger", + "maintain_ledger", + "exception_ledger", + "contract", + "agent", + "raw_commission_data" + ); + + /** LOOKUP 참조 가능 테이블 */ + private static final Set ALLOWED_LOOKUP_TABLES = Set.of( + "agent", + "product", + "contract", + "organization", + "insurance_company", + "common_code" + ); + + public static boolean isAllowedTargetTable(String table) { + return table != null && ALLOWED_TARGET_TABLES.contains(table.toLowerCase()); + } + + public static boolean isAllowedLookupTable(String table) { + return table != null && ALLOWED_LOOKUP_TABLES.contains(table.toLowerCase()); + } + + /** 컬럼명 검증: 식별자 패턴만 통과. (예약어 검사는 필요시 별도) */ + public static boolean isValidIdentifier(String s) { + return s != null && IDENT.matcher(s).matches(); + } + + public static void requireTargetTable(String table) { + if (!isAllowedTargetTable(table)) { + throw new IllegalArgumentException("허용되지 않은 INSERT 대상 테이블: " + table); + } + } + + public static void requireValidIdentifier(String s) { + if (!isValidIdentifier(s)) { + throw new IllegalArgumentException("허용되지 않은 식별자: " + s); + } + } +} diff --git a/ga-common/src/main/java/com/ga/common/validation/CommonValidator.java b/ga-common/src/main/java/com/ga/common/validation/CommonValidator.java new file mode 100644 index 0000000..d64e9d3 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/validation/CommonValidator.java @@ -0,0 +1,157 @@ +package com.ga.common.validation; + +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.regex.Pattern; + +/** + * 자주 쓰는 검증 함수 모음. 정적 메서드로 호출. + * + * 사용: + * CommonValidator.notBlank(req.getName(), "설계사명"); + * CommonValidator.email(req.getEmail()); + * CommonValidator.bizNo(req.getBizNo()); + */ +public final class CommonValidator { + + private CommonValidator() {} + + private static final Pattern EMAIL = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final Pattern PHONE = Pattern.compile("^0\\d{1,2}-?\\d{3,4}-?\\d{4}$"); + private static final Pattern BIZ_NO = Pattern.compile("^\\d{3}-?\\d{2}-?\\d{5}$"); + private static final Pattern RRN = Pattern.compile("^\\d{6}-?\\d{7}$"); + private static final Pattern ACCOUNT = Pattern.compile("^\\d{6,20}(-?\\d{1,10})*$"); + private static final Pattern NUMERIC = Pattern.compile("^-?\\d+(\\.\\d+)?$"); + private static final Pattern PASSWORD = Pattern.compile( + "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-\\[\\]{};:'\"<>,.?/]).{8,20}$"); + + /* ========================================================== + 필수/길이 + ========================================================== */ + public static void notBlank(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_FAIL, fieldName + "은(는) 필수입니다"); + } + } + + public static void notNull(Object value, String fieldName) { + if (value == null) { + throw new BizException(ErrorCode.VALIDATION_FAIL, fieldName + "은(는) 필수입니다"); + } + } + + public static void maxLength(String value, int max, String fieldName) { + if (value != null && value.length() > max) { + throw new BizException(ErrorCode.VALIDATION_FAIL, + fieldName + "은(는) " + max + "자 이내여야 합니다"); + } + } + + public static void minLength(String value, int min, String fieldName) { + if (value == null || value.length() < min) { + throw new BizException(ErrorCode.VALIDATION_FAIL, + fieldName + "은(는) " + min + "자 이상이어야 합니다"); + } + } + + /* ========================================================== + 포맷 (정규식) + ========================================================== */ + public static void email(String value) { + if (value != null && !EMAIL.matcher(value).matches()) { + throw new BizException(ErrorCode.VALIDATION_FAIL, "이메일 형식이 올바르지 않습니다"); + } + } + + public static void phone(String value) { + if (value != null && !PHONE.matcher(value).matches()) { + throw new BizException(ErrorCode.VALIDATION_FAIL, "전화번호 형식이 올바르지 않습니다"); + } + } + + public static void bizNo(String value) { + if (value != null && !BIZ_NO.matcher(value).matches()) { + throw new BizException(ErrorCode.VALIDATION_FAIL, "사업자번호 형식이 올바르지 않습니다"); + } + } + + public static void residentNo(String value) { + if (value != null && !RRN.matcher(value).matches()) { + throw new BizException(ErrorCode.VALIDATION_FAIL, "주민등록번호 형식이 올바르지 않습니다"); + } + } + + public static void accountNo(String value) { + if (value != null && !ACCOUNT.matcher(value).matches()) { + throw new BizException(ErrorCode.VALIDATION_FAIL, "계좌번호 형식이 올바르지 않습니다"); + } + } + + public static void numeric(String value, String fieldName) { + if (value != null && !NUMERIC.matcher(value).matches()) { + throw new BizException(ErrorCode.VALIDATION_FAIL, fieldName + "은(는) 숫자만 입력하세요"); + } + } + + public static void password(String value) { + if (value == null || !PASSWORD.matcher(value).matches()) { + throw new BizException(ErrorCode.PASSWORD_POLICY, + "영문/숫자/특수문자 조합 8~20자로 입력하세요"); + } + } + + /* ========================================================== + 범위/숫자 + ========================================================== */ + public static void positive(BigDecimal value, String fieldName) { + if (value == null || value.signum() <= 0) { + throw new BizException(ErrorCode.VALIDATION_FAIL, fieldName + "은(는) 0보다 커야 합니다"); + } + } + + public static void notNegative(BigDecimal value, String fieldName) { + if (value != null && value.signum() < 0) { + throw new BizException(ErrorCode.VALIDATION_FAIL, fieldName + "은(는) 음수일 수 없습니다"); + } + } + + public static void range(int value, int min, int max, String fieldName) { + if (value < min || value > max) { + throw new BizException(ErrorCode.VALIDATION_FAIL, + fieldName + "은(는) " + min + " ~ " + max + " 범위여야 합니다"); + } + } + + /* ========================================================== + 날짜/기간 + ========================================================== */ + public static void date(String value, String pattern) { + if (value == null) return; + try { + LocalDate.parse(value, DateTimeFormatter.ofPattern(pattern)); + } catch (DateTimeParseException e) { + throw new BizException(ErrorCode.VALIDATION_FAIL, + "날짜 형식이 올바르지 않습니다 (예: " + pattern + ")"); + } + } + + public static void period(LocalDate from, LocalDate to) { + if (from != null && to != null && from.isAfter(to)) { + throw new BizException(ErrorCode.INVALID_PERIOD, "시작일이 종료일보다 늦습니다"); + } + } + + /* ========================================================== + 사용자 정의 + ========================================================== */ + public static void check(boolean condition, ErrorCode errorCode, String message) { + if (!condition) { + throw new BizException(errorCode, message); + } + } +} diff --git a/ga-common/src/main/resources/codegen/controller/EntityController.java b/ga-common/src/main/resources/codegen/controller/EntityController.java new file mode 100644 index 0000000..0b369c7 --- /dev/null +++ b/ga-common/src/main/resources/codegen/controller/EntityController.java @@ -0,0 +1,59 @@ +package com.ga.api.controller.${GROUP}; + +import com.ga.api.service.${GROUP}.${ENTITY}Service; +import com.ga.common.annotation.DataChangeLog; +import com.ga.common.annotation.RequirePermission; +import com.ga.common.model.ApiResponse; +import com.ga.common.model.PageResponse; +import com.ga.core.vo.${GROUP}.${ENTITY}Resp; +import com.ga.core.vo.${GROUP}.${ENTITY}SaveReq; +import com.ga.core.vo.${GROUP}.${ENTITY}SearchParam; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/${TABLE}s") +@Tag(name = "${LABEL}") +@RequiredArgsConstructor +public class ${ENTITY}Controller { + + private final ${ENTITY}Service ${ENTITY_CAMEL}Service; + + @GetMapping + @RequirePermission(menu = "${MENU}", perm = "READ") + public ApiResponse> list(${ENTITY}SearchParam param) { + return ApiResponse.ok(${ENTITY_CAMEL}Service.list(param)); + } + + @GetMapping("/{id}") + @RequirePermission(menu = "${MENU}", perm = "READ") + public ApiResponse<${ENTITY}Resp> detail(@PathVariable Long id) { + return ApiResponse.ok(${ENTITY_CAMEL}Service.detail(id)); + } + + @PostMapping + @RequirePermission(menu = "${MENU}", perm = "CREATE") + @DataChangeLog(menu = "${MENU}", table = "${TABLE}") + public ApiResponse create(@Valid @RequestBody ${ENTITY}SaveReq req) { + ${ENTITY_CAMEL}Service.create(req); + return ApiResponse.ok(); + } + + @PutMapping("/{id}") + @RequirePermission(menu = "${MENU}", perm = "UPDATE") + @DataChangeLog(menu = "${MENU}", table = "${TABLE}") + public ApiResponse update(@PathVariable Long id, @Valid @RequestBody ${ENTITY}SaveReq req) { + ${ENTITY_CAMEL}Service.update(id, req); + return ApiResponse.ok(); + } + + @DeleteMapping("/{id}") + @RequirePermission(menu = "${MENU}", perm = "DELETE") + @DataChangeLog(menu = "${MENU}", table = "${TABLE}") + public ApiResponse delete(@PathVariable Long id) { + ${ENTITY_CAMEL}Service.delete(id); + return ApiResponse.ok(); + } +} diff --git a/ga-common/src/main/resources/codegen/frontend/EntityDetail.tsx b/ga-common/src/main/resources/codegen/frontend/EntityDetail.tsx new file mode 100644 index 0000000..083cea3 --- /dev/null +++ b/ga-common/src/main/resources/codegen/frontend/EntityDetail.tsx @@ -0,0 +1,33 @@ +import { Form, Input, Button, Space } from 'antd'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { ${ENTITY_CAMEL}Api } from '@/api/${ENTITY_CAMEL}'; + +export default function ${ENTITY}Detail() { + const { id } = useParams(); + const nav = useNavigate(); + const [form] = Form.useForm(); + + const { data } = useQuery({ + queryKey: ['${ENTITY_CAMEL}', id], + queryFn: () => ${ENTITY_CAMEL}Api.detail(Number(id)), + enabled: !!id, + }); + + const save = useMutation({ + mutationFn: (values: any) => id + ? ${ENTITY_CAMEL}Api.update(Number(id), values) + : ${ENTITY_CAMEL}Api.create(values), + onSuccess: () => nav(-1), + }); + + return ( +
+ {/* 입력 폼 필드 추가 */} + + + + +
+ ); +} diff --git a/ga-common/src/main/resources/codegen/frontend/EntityList.tsx b/ga-common/src/main/resources/codegen/frontend/EntityList.tsx new file mode 100644 index 0000000..7421736 --- /dev/null +++ b/ga-common/src/main/resources/codegen/frontend/EntityList.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { Button, Space, Table } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { ${ENTITY_CAMEL}Api } from '@/api/${ENTITY_CAMEL}'; +import { SearchForm } from '@/components/common/SearchForm'; +import { PermissionButton } from '@/components/common/PermissionButton'; + +export default function ${ENTITY}List() { + const [param, setParam] = useState({ pageNum: 1, pageSize: 20 }); + const { data, isLoading, refetch } = useQuery({ + queryKey: ['${ENTITY_CAMEL}', param], + queryFn: () => ${ENTITY_CAMEL}Api.list(param), + }); + + return ( +
+ setParam({ ...param, ...v, pageNum: 1 })} /> + + 신규 + + setParam({ ...param, pageNum, pageSize }), + }} + columns={[ + // 컬럼을 추가하세요. + ]} + /> + + ); +} diff --git a/ga-common/src/main/resources/codegen/frontend/entity.ts b/ga-common/src/main/resources/codegen/frontend/entity.ts new file mode 100644 index 0000000..b5c63af --- /dev/null +++ b/ga-common/src/main/resources/codegen/frontend/entity.ts @@ -0,0 +1,9 @@ +import { request } from './request'; + +export const ${ENTITY_CAMEL}Api = { + list: (params: any) => request.get('/api/${TABLE}s', { params }), + detail: (id: number) => request.get(`/api/${TABLE}s/${'${id}'}`), + create: (data: any) => request.post('/api/${TABLE}s', data), + update: (id: number, data: any) => request.put(`/api/${TABLE}s/${'${id}'}`, data), + delete: (id: number) => request.delete(`/api/${TABLE}s/${'${id}'}`), +}; diff --git a/ga-common/src/main/resources/codegen/mapper/EntityMapper.java b/ga-common/src/main/resources/codegen/mapper/EntityMapper.java new file mode 100644 index 0000000..02fb3da --- /dev/null +++ b/ga-common/src/main/resources/codegen/mapper/EntityMapper.java @@ -0,0 +1,16 @@ +package com.ga.core.mapper.${GROUP}; + +import com.ga.core.vo.${GROUP}.${ENTITY}VO; +import com.ga.core.vo.${GROUP}.${ENTITY}Resp; +import com.ga.core.vo.${GROUP}.${ENTITY}SearchParam; +import org.apache.ibatis.annotations.Mapper; +import java.util.List; + +@Mapper +public interface ${ENTITY}Mapper { + List<${ENTITY}Resp> selectList(${ENTITY}SearchParam param); + ${ENTITY}Resp selectById(Long id); + int insert(${ENTITY}VO vo); + int update(${ENTITY}VO vo); + int deleteById(Long id); +} diff --git a/ga-common/src/main/resources/codegen/mapper/EntityMapper.xml b/ga-common/src/main/resources/codegen/mapper/EntityMapper.xml new file mode 100644 index 0000000..27fbb02 --- /dev/null +++ b/ga-common/src/main/resources/codegen/mapper/EntityMapper.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + INSERT INTO ${TABLE} ( + ${INSERT_COLUMNS} + ) VALUES ( + ${INSERT_VALUES} + ) + + + + UPDATE ${TABLE} + SET ${UPDATE_SETS} + WHERE /* PK 조건 */ 1=1 + + + + DELETE FROM ${TABLE} + WHERE /* PK 조건 */ 1=1 + + diff --git a/ga-common/src/main/resources/codegen/service/EntityService.java b/ga-common/src/main/resources/codegen/service/EntityService.java new file mode 100644 index 0000000..b711990 --- /dev/null +++ b/ga-common/src/main/resources/codegen/service/EntityService.java @@ -0,0 +1,51 @@ +package com.ga.api.service.${GROUP}; + +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.common.model.PageResponse; +import com.ga.core.mapper.${GROUP}.${ENTITY}Mapper; +import com.ga.core.vo.${GROUP}.${ENTITY}Resp; +import com.ga.core.vo.${GROUP}.${ENTITY}SaveReq; +import com.ga.core.vo.${GROUP}.${ENTITY}SearchParam; +import com.ga.core.vo.${GROUP}.${ENTITY}VO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ${ENTITY}Service { + + private final ${ENTITY}Mapper ${ENTITY_CAMEL}Mapper; + + public PageResponse<${ENTITY}Resp> list(${ENTITY}SearchParam param) { + param.startPage(); + return PageResponse.of(${ENTITY_CAMEL}Mapper.selectList(param)); + } + + public ${ENTITY}Resp detail(Long id) { + ${ENTITY}Resp r = ${ENTITY_CAMEL}Mapper.selectById(id); + if (r == null) throw new BizException(ErrorCode.NOT_FOUND); + return r; + } + + @Transactional + public void create(${ENTITY}SaveReq req) { + ${ENTITY}VO vo = new ${ENTITY}VO(); + org.springframework.beans.BeanUtils.copyProperties(req, vo); + ${ENTITY_CAMEL}Mapper.insert(vo); + } + + @Transactional + public void update(Long id, ${ENTITY}SaveReq req) { + ${ENTITY}VO vo = new ${ENTITY}VO(); + org.springframework.beans.BeanUtils.copyProperties(req, vo); + // PK 세팅 — 테이블 PK명에 맞게 수정하세요. + ${ENTITY_CAMEL}Mapper.update(vo); + } + + @Transactional + public void delete(Long id) { + ${ENTITY_CAMEL}Mapper.deleteById(id); + } +} diff --git a/ga-common/src/main/resources/codegen/vo/Entity.vo.java b/ga-common/src/main/resources/codegen/vo/Entity.vo.java new file mode 100644 index 0000000..9db8ef7 --- /dev/null +++ b/ga-common/src/main/resources/codegen/vo/Entity.vo.java @@ -0,0 +1,11 @@ +package com.ga.core.vo.${GROUP}; + +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** ${TABLE} 테이블 1:1 매핑. INSERT/UPDATE에 사용. */ +@Data +public class ${ENTITY}VO { +${FIELDS_VO}} diff --git a/ga-common/src/main/resources/codegen/vo/EntityResp.java b/ga-common/src/main/resources/codegen/vo/EntityResp.java new file mode 100644 index 0000000..d8d8b2d --- /dev/null +++ b/ga-common/src/main/resources/codegen/vo/EntityResp.java @@ -0,0 +1,11 @@ +package com.ga.core.vo.${GROUP}; + +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** ${TABLE} 응답. 조인 결과(다른 테이블 이름 필드 등)를 추가하세요. */ +@Data +public class ${ENTITY}Resp { +${FIELDS_RESP}} diff --git a/ga-common/src/main/resources/codegen/vo/EntitySaveReq.java b/ga-common/src/main/resources/codegen/vo/EntitySaveReq.java new file mode 100644 index 0000000..a0f5b29 --- /dev/null +++ b/ga-common/src/main/resources/codegen/vo/EntitySaveReq.java @@ -0,0 +1,10 @@ +package com.ga.core.vo.${GROUP}; + +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; + +/** ${TABLE} 등록/수정 요청. ID/감사필드는 제외. */ +@Data +public class ${ENTITY}SaveReq { +${FIELDS_SAVEREQ}} diff --git a/ga-common/src/main/resources/codegen/vo/EntitySearchParam.java b/ga-common/src/main/resources/codegen/vo/EntitySearchParam.java new file mode 100644 index 0000000..ecb92e9 --- /dev/null +++ b/ga-common/src/main/resources/codegen/vo/EntitySearchParam.java @@ -0,0 +1,12 @@ +package com.ga.core.vo.${GROUP}; + +import com.ga.common.model.SearchParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** ${TABLE} 검색 조건. SearchParam의 공통 필드(pageNum/pageSize/searchKeyword 등) 상속. */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ${ENTITY}SearchParam extends SearchParam { + // 화면별 필터 필드를 추가하세요. +} diff --git a/ga-common/src/main/resources/db/migration/V18__업로드템플릿.sql b/ga-common/src/main/resources/db/migration/V18__업로드템플릿.sql new file mode 100644 index 0000000..64d1c2a --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V18__업로드템플릿.sql @@ -0,0 +1,94 @@ +-- V18: 엑셀 업로드 매핑 템플릿 시스템 +-- 보험사/시기별로 엑셀 양식이 다른 문제를 해결. +-- 화면에서 매핑 규칙을 관리하면, 같은 코드로 다양한 엑셀을 처리한다. + +-- 업로드 템플릿 마스터 +CREATE TABLE upload_template ( + template_id SERIAL PRIMARY KEY, + template_code VARCHAR(50) NOT NULL UNIQUE, -- SAMSUNG_RECRUIT, HANWHA_MAINTAIN ... + template_name VARCHAR(100) NOT NULL, -- "삼성생명 모집수수료" + category VARCHAR(30) NOT NULL, -- RECRUIT / MAINTAIN / CONTRACT / AGENT / CUSTOM + target_table VARCHAR(50) NOT NULL, -- 적재 대상 테이블 (recruit_ledger 등) + company_code VARCHAR(20), -- 보험사코드 (NULL이면 범용) + header_row INT NOT NULL DEFAULT 1, -- 헤더 행 번호 (1부터) + data_start_row INT NOT NULL DEFAULT 2, -- 데이터 시작 행 (1부터) + sheet_index INT NOT NULL DEFAULT 0, -- 시트 인덱스 (0부터) + sheet_name VARCHAR(50), -- 시트명 (지정 시 인덱스 무시) + file_encoding VARCHAR(20) DEFAULT 'UTF-8', -- CSV 인코딩 + delimiter VARCHAR(5), -- CSV 구분자 + skip_empty_row CHAR(1) DEFAULT 'Y', -- 빈 행 건너뛰기 + max_error_count INT DEFAULT 1000, -- 최대 허용 에러 수 + batch_size INT DEFAULT 1000, -- 배치 INSERT 크기 + description VARCHAR(200), + is_active CHAR(1) DEFAULT 'Y', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by BIGINT, + updated_at TIMESTAMP, + updated_by BIGINT +); +CREATE INDEX idx_upload_tpl_category ON upload_template(category); +CREATE INDEX idx_upload_tpl_company ON upload_template(company_code); + +-- 업로드 템플릿 컬럼 매핑 +CREATE TABLE upload_template_column ( + column_id SERIAL PRIMARY KEY, + template_id BIGINT NOT NULL REFERENCES upload_template(template_id) ON DELETE CASCADE, + sort_order INT NOT NULL, + + -- 소스 (엑셀) + source_type VARCHAR(10) NOT NULL DEFAULT 'COLUMN', -- COLUMN / FIXED / EXPRESSION + source_column VARCHAR(10), -- A, B, C... 또는 0, 1, 2 인덱스 + source_header VARCHAR(100), -- 헤더명 (자동 탐지용) + + -- 타겟 (DB) + target_field VARCHAR(50) NOT NULL, -- DB 컬럼명 + target_type VARCHAR(20) NOT NULL DEFAULT 'STRING', -- STRING / NUMBER / DECIMAL / DATE / INTEGER + + -- 변환 규칙 + transform_rule VARCHAR(30), -- TRIM / UPPER / LOWER / DATE / DIVIDE / MULTIPLY / LOOKUP / CODE_MAP / REPLACE / SUBSTRING / CONCAT / DEFAULT + transform_param VARCHAR(200), -- JSON 또는 단순값 + date_format VARCHAR(30), -- yyyyMMdd / yyyy-MM-dd ... + number_divide INT, -- 나누기 (전→원: 100) + number_multiply INT, -- 곱하기 + fixed_value VARCHAR(200), -- source_type=FIXED일 때 + default_value VARCHAR(200), -- NULL일 때 기본값 + + -- 조회 변환 (LOOKUP) + lookup_table VARCHAR(50), -- 참조 테이블 + lookup_source_field VARCHAR(50), -- 참조 검색 필드 + lookup_target_field VARCHAR(50), -- 참조 결과 필드 + + -- 코드 매핑 + code_map_group VARCHAR(50), -- code_mapping 그룹 + + -- 검증 + is_required CHAR(1) DEFAULT 'N', + validation_regex VARCHAR(200), + validation_message VARCHAR(200), + + description VARCHAR(200), + UNIQUE(template_id, sort_order) +); +CREATE INDEX idx_upload_tpl_col ON upload_template_column(template_id); + +-- 업로드 이력 +CREATE TABLE upload_history ( + history_id SERIAL PRIMARY KEY, + template_id BIGINT NOT NULL REFERENCES upload_template(template_id), + template_code VARCHAR(50) NOT NULL, + file_name VARCHAR(300) NOT NULL, + file_size BIGINT, + total_count INT NOT NULL DEFAULT 0, + success_count INT NOT NULL DEFAULT 0, + error_count INT NOT NULL DEFAULT 0, + skip_count INT NOT NULL DEFAULT 0, + error_file_id BIGINT, -- file_storage FK (에러 엑셀) + status VARCHAR(10) NOT NULL DEFAULT 'PROCESSING', -- PROCESSING / COMPLETED / FAILED / CANCELLED + error_message TEXT, + started_at TIMESTAMP NOT NULL DEFAULT NOW(), + finished_at TIMESTAMP, + duration_sec INT, + created_by BIGINT +); +CREATE INDEX idx_upload_history_tpl ON upload_history(template_id, started_at); +CREATE INDEX idx_upload_history_user ON upload_history(created_by, started_at); diff --git a/ga-common/src/main/resources/db/migration/V19__dba검토_보강.sql b/ga-common/src/main/resources/db/migration/V19__dba검토_보강.sql new file mode 100644 index 0000000..05a3e35 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V19__dba검토_보강.sql @@ -0,0 +1,84 @@ +-- V19: DBA 검토 결과 보강 +-- +-- V13~V15 검토 결과 누락된 인덱스/통계 갱신을 추가. +-- 1만건 ~ 1000만건 가정 EXPLAIN ANALYZE 결과는 각 SECTION 상단 코멘트 참고. + +-- ======================================================================== +-- [1] AGENT 검색 인덱스 보강 +-- 사용처: AgentMapper.selectList (재귀 CTE 포함) +-- 검토: 기존 PK + (org_id), (license_no UNIQUE) 외에 status 별 카운트가 빈번 +-- 결과: 1만건 시 Seq Scan → idx 사용 필요 +-- ======================================================================== +CREATE INDEX IF NOT EXISTS idx_agent_status_org ON agent(status, org_id); +CREATE INDEX IF NOT EXISTS idx_agent_grade ON agent(grade_id) WHERE status = 'ACTIVE'; + +-- ======================================================================== +-- [2] CONTRACT 빈번 검색 +-- 사용처: AgentResp.contract_count subquery, ContractMapper.selectList +-- 검토: contract(agent_id, status) 필요 (V13 이미 status,agent_id 있음 — 순서 검토) +-- 결과: 복합 인덱스 (agent_id, status) 가 contract_count subquery에 더 효율적 +-- ======================================================================== +CREATE INDEX IF NOT EXISTS idx_contract_agent_status ON contract(agent_id, status); +CREATE INDEX IF NOT EXISTS idx_contract_policy ON contract(policy_no); -- UNIQUE라면 자동 — 없으면 추가 + +-- ======================================================================== +-- [3] LEDGER 부분 인덱스 — 미정산건 조회 빠르게 +-- 검토: reconcile_status='PENDING' 인 행만 조회하는 패턴이 많음 +-- ======================================================================== +CREATE INDEX IF NOT EXISTS idx_rl_pending ON recruit_ledger(settle_month, agent_id) + WHERE reconcile_status = 'PENDING'; +CREATE INDEX IF NOT EXISTS idx_ml_pending ON maintain_ledger(settle_month, agent_id) + WHERE reconcile_status = 'PENDING'; + +-- ======================================================================== +-- [4] UPLOAD_HISTORY 사용자별 최근 이력 +-- 사용처: 업로드 화면 마이페이지 +-- ======================================================================== +CREATE INDEX IF NOT EXISTS idx_upload_history_user_recent + ON upload_history(created_by, started_at DESC); + +-- ======================================================================== +-- [5] BATCH_JOB_LOG 모니터링 (현재 실행중 잡 빠른 조회) +-- ======================================================================== +CREATE INDEX IF NOT EXISTS idx_batch_running ON batch_job_log(job_name, status) + WHERE status IN ('STARTED', 'RUNNING'); + +-- ======================================================================== +-- [6] 통계 업데이트 +-- 대량 데이터 적재 후에는 수동으로 ANALYZE 호출 권장. +-- 운영에서는 autovacuum 보조용으로 주요 테이블만 명시. +-- ======================================================================== +ANALYZE agent; +ANALYZE contract; +ANALYZE recruit_ledger; +ANALYZE maintain_ledger; +ANALYZE settle_master; +ANALYZE chargeback; +ANALYZE payment; + +-- ======================================================================== +-- [참고] EXPLAIN ANALYZE 권장 시나리오 +-- 운영 적재 후 다음 쿼리들의 실행계획을 확인할 것: +-- +-- -- (1) 설계사 목록 (org 하위 포함) +-- EXPLAIN ANALYZE +-- SELECT * FROM agent a +-- JOIN organization o ON o.org_id = a.org_id +-- WHERE a.org_id IN (WITH RECURSIVE d AS ( +-- SELECT org_id FROM organization WHERE org_id = 1 +-- UNION ALL +-- SELECT c.org_id FROM organization c JOIN d ON c.parent_org_id = d.org_id +-- ) SELECT org_id FROM d) +-- AND a.status = 'ACTIVE'; +-- 기대: Index Scan idx_agent_status_org + CTE Scan +-- +-- -- (2) 정산 집계 +-- EXPLAIN ANALYZE +-- SELECT settle_month, agent_id, SUM(net_amount) +-- FROM settle_master WHERE settle_month BETWEEN '202401' AND '202412' +-- GROUP BY settle_month, agent_id; +-- 기대: Index Only Scan idx_sm_month_status +-- +-- -- (3) 환수 위험 (V15 v_chargeback_risk 가정) +-- EXPLAIN ANALYZE SELECT * FROM v_chargeback_risk LIMIT 100; +-- ======================================================================== diff --git a/ga-common/src/main/resources/mapper/common/UploadMapper.xml b/ga-common/src/main/resources/mapper/common/UploadMapper.xml new file mode 100644 index 0000000..7608ee0 --- /dev/null +++ b/ga-common/src/main/resources/mapper/common/UploadMapper.xml @@ -0,0 +1,188 @@ + + + + + + + template_id, template_code, template_name, category, target_table, company_code, + header_row, data_start_row, sheet_index, sheet_name, file_encoding, delimiter, + skip_empty_row, max_error_count, batch_size, description, is_active, + created_at, created_by, updated_at, updated_by + + + + + + + + + + INSERT INTO upload_template ( + template_code, template_name, category, target_table, company_code, + header_row, data_start_row, sheet_index, sheet_name, file_encoding, delimiter, + skip_empty_row, max_error_count, batch_size, description, is_active, + created_by + ) VALUES ( + #{templateCode}, #{templateName}, #{category}, #{targetTable}, #{companyCode}, + #{headerRow}, #{dataStartRow}, #{sheetIndex}, #{sheetName}, #{fileEncoding}, #{delimiter}, + #{skipEmptyRow}, #{maxErrorCount}, #{batchSize}, #{description}, COALESCE(#{isActive}, 'Y'), + #{createdBy} + ) + + + + UPDATE upload_template + SET template_name = #{templateName}, + category = #{category}, + target_table = #{targetTable}, + company_code = #{companyCode}, + header_row = #{headerRow}, + data_start_row = #{dataStartRow}, + sheet_index = #{sheetIndex}, + sheet_name = #{sheetName}, + file_encoding = #{fileEncoding}, + delimiter = #{delimiter}, + skip_empty_row = #{skipEmptyRow}, + max_error_count = #{maxErrorCount}, + batch_size = #{batchSize}, + description = #{description}, + is_active = #{isActive}, + updated_at = NOW(), + updated_by = #{updatedBy} + WHERE template_id = #{templateId} + + + + DELETE FROM upload_template WHERE template_id = #{id} + + + + + + + INSERT INTO upload_template_column ( + template_id, sort_order, + source_type, source_column, source_header, + target_field, target_type, + transform_rule, transform_param, date_format, number_divide, number_multiply, + fixed_value, default_value, + lookup_table, lookup_source_field, lookup_target_field, + code_map_group, + is_required, validation_regex, validation_message, + description + ) VALUES ( + #{templateId}, #{sortOrder}, + COALESCE(#{sourceType}, 'COLUMN'), #{sourceColumn}, #{sourceHeader}, + #{targetField}, COALESCE(#{targetType}, 'STRING'), + #{transformRule}, #{transformParam}, #{dateFormat}, #{numberDivide}, #{numberMultiply}, + #{fixedValue}, #{defaultValue}, + #{lookupTable}, #{lookupSourceField}, #{lookupTargetField}, + #{codeMapGroup}, + COALESCE(#{isRequired}, 'N'), #{validationRegex}, #{validationMessage}, + #{description} + ) + + + + DELETE FROM upload_template_column WHERE template_id = #{templateId} + + + + + INSERT INTO upload_history ( + template_id, template_code, file_name, file_size, + total_count, success_count, error_count, skip_count, + error_file_id, status, error_message, + started_at, finished_at, duration_sec, created_by + ) VALUES ( + #{templateId}, #{templateCode}, #{fileName}, #{fileSize}, + #{totalCount}, #{successCount}, #{errorCount}, #{skipCount}, + #{errorFileId}, COALESCE(#{status}, 'PROCESSING'), #{errorMessage}, + COALESCE(#{startedAt}, NOW()), #{finishedAt}, #{durationSec}, #{createdBy} + ) + + + + UPDATE upload_history + SET total_count = #{totalCount}, + success_count = #{successCount}, + error_count = #{errorCount}, + skip_count = #{skipCount}, + error_file_id = #{errorFileId}, + status = #{status}, + error_message = #{errorMessage}, + finished_at = #{finishedAt}, + duration_sec = #{durationSec} + WHERE history_id = #{historyId} + + + + + + + INSERT INTO ${targetTable} ( + ${field} + ) VALUES + + (#{row[${field}]}) + + + + + + diff --git a/ga-common/src/main/resources/messages/messages_en.properties b/ga-common/src/main/resources/messages/messages_en.properties new file mode 100644 index 0000000..8cee5eb --- /dev/null +++ b/ga-common/src/main/resources/messages/messages_en.properties @@ -0,0 +1,32 @@ +# Common messages (English) + +# Common +common.success=Processed successfully. +common.confirm=Do you want to continue? +common.delete.confirm=Are you sure you want to delete? + +# Validation +validation.required={0} is required. +validation.maxLength={0} must be at most {1} characters. +validation.minLength={0} must be at least {1} characters. +validation.email=Invalid email format. +validation.phone=Invalid phone number format. +validation.bizNo=Invalid business registration number. +validation.residentNo=Invalid resident registration number. +validation.accountNo=Invalid bank account number. +validation.numeric={0} must be numeric. +validation.amount=Invalid amount. +validation.date=Invalid date format. +validation.range={0} must be between {1} and {2}. + +# Upload +upload.template.notFound=No upload template registered. +upload.template.duplicate=A template with the same code already exists. +upload.column.duplicate=A mapping with the same order already exists. +upload.preview.fail=Failed to preview the Excel file. +upload.processing=Upload in progress. +upload.complete={0} rows uploaded successfully ({1} failed) + +# Permission/Audit +auth.permission.required=You do not have permission for this action. +audit.changed={0} change(s) have been recorded. diff --git a/ga-common/src/main/resources/messages/messages_ko.properties b/ga-common/src/main/resources/messages/messages_ko.properties new file mode 100644 index 0000000..182bd7a --- /dev/null +++ b/ga-common/src/main/resources/messages/messages_ko.properties @@ -0,0 +1,33 @@ +# 공통 메시지 (한국어) +# ErrorCode enum의 message는 enum에서 직접 사용하고, 여기는 화면/검증 메시지 위주. + +# 공통 +common.success=처리되었습니다. +common.confirm=계속 진행하시겠습니까? +common.delete.confirm=정말 삭제하시겠습니까? + +# 검증 +validation.required={0}은(는) 필수입니다. +validation.maxLength={0}은(는) {1}자 이내로 입력하세요. +validation.minLength={0}은(는) {1}자 이상 입력하세요. +validation.email=이메일 형식이 올바르지 않습니다. +validation.phone=전화번호 형식이 올바르지 않습니다. +validation.bizNo=사업자번호 형식이 올바르지 않습니다. +validation.residentNo=주민등록번호 형식이 올바르지 않습니다. +validation.accountNo=계좌번호 형식이 올바르지 않습니다. +validation.numeric={0}은(는) 숫자만 입력 가능합니다. +validation.amount=금액이 올바르지 않습니다. +validation.date=날짜 형식이 올바르지 않습니다. +validation.range={0}은(는) {1} ~ {2} 범위여야 합니다. + +# 업로드 +upload.template.notFound=등록된 업로드 템플릿이 없습니다. +upload.template.duplicate=이미 동일한 코드의 템플릿이 존재합니다. +upload.column.duplicate=동일한 순서의 매핑이 존재합니다. +upload.preview.fail=엑셀 미리보기에 실패했습니다. +upload.processing=업로드 처리 중입니다. +upload.complete={0}건 업로드 완료 (실패 {1}건) + +# 권한/감사 +auth.permission.required=해당 작업에 필요한 권한이 없습니다. +audit.changed={0}건의 변경 이력이 기록되었습니다.