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,18 @@
package com.ga.core.mapper.upload;
import com.ga.core.vo.upload.UploadTemplateColumnVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UploadColumnMapper {
List<UploadTemplateColumnVO> selectByTemplateId(@Param("templateId") Long templateId);
int insertBatch(@Param("templateId") Long templateId,
@Param("columns") List<UploadTemplateColumnVO> columns);
int deleteByTemplateId(@Param("templateId") Long templateId);
}
@@ -0,0 +1,30 @@
package com.ga.core.mapper.upload;
import com.ga.core.vo.upload.UploadHistoryResp;
import com.ga.core.vo.upload.UploadHistoryVO;
import com.ga.core.vo.upload.UploadTemplateSearchParam;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UploadHistoryMapper {
List<UploadHistoryResp> selectList(UploadTemplateSearchParam param);
List<UploadHistoryResp> selectByTemplate(@Param("templateId") Long templateId,
UploadTemplateSearchParam param);
int insert(UploadHistoryVO vo);
int updateStatus(@Param("historyId") Long historyId,
@Param("status") String status,
@Param("errorMessage") String errorMessage,
@Param("successCount") Integer successCount,
@Param("errorCount") Integer errorCount,
@Param("skipCount") Integer skipCount,
@Param("totalCount") Integer totalCount,
@Param("errorFileId") Long errorFileId,
@Param("durationSec") Integer durationSec);
}
@@ -0,0 +1,27 @@
package com.ga.core.mapper.upload;
import com.ga.core.vo.upload.UploadTemplateResp;
import com.ga.core.vo.upload.UploadTemplateSearchParam;
import com.ga.core.vo.upload.UploadTemplateVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UploadTemplateMapper {
List<UploadTemplateResp> selectList(UploadTemplateSearchParam param);
UploadTemplateResp selectById(@Param("templateId") Long templateId);
UploadTemplateVO selectByCode(@Param("templateCode") String templateCode);
int existsByCode(@Param("templateCode") String templateCode);
int insert(UploadTemplateVO vo);
int update(UploadTemplateVO vo);
int delete(@Param("templateId") Long templateId);
}
@@ -0,0 +1,31 @@
package com.ga.core.vo.upload;
import lombok.Data;
import java.time.LocalDateTime;
/**
* upload_history 응답 VO (목록/상세용).
*/
@Data
public class UploadHistoryResp {
private Long historyId;
private Long templateId;
private String templateCode;
private String templateName;
private String fileName;
private Long fileSize;
private Integer totalCount;
private Integer successCount;
private Integer errorCount;
private Integer skipCount;
private Long errorFileId;
private String status;
private String errorMessage;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private Integer durationSec;
private Long createdBy;
private String createdByName;
}
@@ -0,0 +1,32 @@
package com.ga.core.vo.upload;
import lombok.Data;
import java.time.LocalDateTime;
/**
* upload_history 테이블 1:1 VO.
*/
@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;
/** PROCESSING / SUCCESS / PARTIAL / FAIL */
private String status;
private String errorMessage;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private Integer durationSec;
private Long createdBy;
}
@@ -0,0 +1,39 @@
package com.ga.core.vo.upload;
import lombok.Data;
/**
* upload_template_column 테이블 1:1 VO.
*/
@Data
public class UploadTemplateColumnVO {
private Long columnId;
private Long templateId;
private Integer sortOrder;
/** COLUMN / FIXED / EXPRESSION */
private String sourceType;
private Integer sourceColumn;
private String sourceHeader;
private String targetField;
/** STRING / NUMBER / DECIMAL / DATE / INTEGER */
private String targetType;
private String transformRule;
private String transformParam;
private String dateFormat;
private Integer numberDivide;
private Integer numberMultiply;
private String fixedValue;
private String defaultValue;
private String lookupTable;
private String lookupSourceField;
private String lookupTargetField;
private String codeMappingGroup;
private String isRequired;
private String validationRegex;
private String validationMessage;
private String description;
}
@@ -0,0 +1,37 @@
package com.ga.core.vo.upload;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* upload_template 목록/상세 응답 VO.
* 상세 조회 시 columns 가 채워진다.
*/
@Data
public class UploadTemplateResp {
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 LocalDateTime updatedAt;
/** 상세 조회 시 포함 (목록에서는 null) */
private List<UploadTemplateColumnVO> columns;
}
@@ -0,0 +1,63 @@
package com.ga.core.vo.upload;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 업로드 템플릿 등록/수정 요청.
*/
@Data
public class UploadTemplateSaveReq {
@NotBlank
private String templateCode;
@NotBlank
private String templateName;
private String category;
@NotBlank
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;
@Valid
private List<UploadTemplateColumnVO> columns;
public UploadTemplateVO toVO() {
UploadTemplateVO vo = new UploadTemplateVO();
vo.setTemplateCode(this.templateCode);
vo.setTemplateName(this.templateName);
vo.setCategory(this.category);
vo.setTargetTable(this.targetTable);
vo.setCompanyCode(this.companyCode);
vo.setHeaderRow(this.headerRow != null ? this.headerRow : 1);
vo.setDataStartRow(this.dataStartRow != null ? this.dataStartRow : 2);
vo.setSheetIndex(this.sheetIndex != null ? this.sheetIndex : 0);
vo.setSheetName(this.sheetName);
vo.setFileEncoding(this.fileEncoding != null ? this.fileEncoding : "UTF-8");
vo.setDelimiter(this.delimiter);
vo.setSkipEmptyRow(this.skipEmptyRow != null ? this.skipEmptyRow : "Y");
vo.setMaxErrorCount(this.maxErrorCount != null ? this.maxErrorCount : 100);
vo.setBatchSize(this.batchSize != null ? this.batchSize : 1000);
vo.setDescription(this.description);
vo.setIsActive(this.isActive != null ? this.isActive : "Y");
return vo;
}
}
@@ -0,0 +1,17 @@
package com.ga.core.vo.upload;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 업로드 템플릿 검색 파라미터.
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class UploadTemplateSearchParam extends SearchParam {
private String category;
private String companyCode;
private String isActive;
}
@@ -0,0 +1,34 @@
package com.ga.core.vo.upload;
import lombok.Data;
import java.time.LocalDateTime;
/**
* upload_template 테이블 1:1 VO.
*/
@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;
}
@@ -0,0 +1,67 @@
<?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.core.mapper.upload.UploadColumnMapper">
<resultMap id="ColumnMap" type="com.ga.core.vo.upload.UploadTemplateColumnVO">
<id property="columnId" column="column_id"/>
<result property="templateId" column="template_id"/>
<result property="sortOrder" column="sort_order"/>
<result property="sourceType" column="source_type"/>
<result property="sourceColumn" column="source_column"/>
<result property="sourceHeader" column="source_header"/>
<result property="targetField" column="target_field"/>
<result property="targetType" column="target_type"/>
<result property="transformRule" column="transform_rule"/>
<result property="transformParam" column="transform_param"/>
<result property="dateFormat" column="date_format"/>
<result property="numberDivide" column="number_divide"/>
<result property="numberMultiply" column="number_multiply"/>
<result property="fixedValue" column="fixed_value"/>
<result property="defaultValue" column="default_value"/>
<result property="lookupTable" column="lookup_table"/>
<result property="lookupSourceField" column="lookup_source_field"/>
<result property="lookupTargetField" column="lookup_target_field"/>
<result property="codeMappingGroup" column="code_map_group"/>
<result property="isRequired" column="is_required"/>
<result property="validationRegex" column="validation_regex"/>
<result property="validationMessage" column="validation_message"/>
<result property="description" column="description"/>
</resultMap>
<select id="selectByTemplateId" resultMap="ColumnMap">
SELECT column_id, 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
FROM upload_template_column
WHERE template_id = #{templateId}
ORDER BY sort_order ASC
</select>
<!-- forEach 로 다건 INSERT -->
<insert id="insertBatch">
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
<foreach collection="columns" item="c" separator=",">
(
#{templateId}, #{c.sortOrder}, #{c.sourceType}, #{c.sourceColumn}, #{c.sourceHeader},
#{c.targetField}, #{c.targetType}, #{c.transformRule}, #{c.transformParam}, #{c.dateFormat},
#{c.numberDivide}, #{c.numberMultiply}, #{c.fixedValue}, #{c.defaultValue},
#{c.lookupTable}, #{c.lookupSourceField}, #{c.lookupTargetField}, #{c.codeMappingGroup},
#{c.isRequired}, #{c.validationRegex}, #{c.validationMessage}, #{c.description}
)
</foreach>
</insert>
<delete id="deleteByTemplateId">
DELETE FROM upload_template_column WHERE template_id = #{templateId}
</delete>
</mapper>
@@ -0,0 +1,101 @@
<?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.core.mapper.upload.UploadHistoryMapper">
<resultMap id="HistoryRespMap" type="com.ga.core.vo.upload.UploadHistoryResp">
<id property="historyId" column="history_id"/>
<result property="templateId" column="template_id"/>
<result property="templateCode" column="template_code"/>
<result property="templateName" column="template_name"/>
<result property="fileName" column="file_name"/>
<result property="fileSize" column="file_size"/>
<result property="totalCount" column="total_count"/>
<result property="successCount" column="success_count"/>
<result property="errorCount" column="error_count"/>
<result property="skipCount" column="skip_count"/>
<result property="errorFileId" column="error_file_id"/>
<result property="status" column="status"/>
<result property="errorMessage" column="error_message"/>
<result property="startedAt" column="started_at"/>
<result property="finishedAt" column="finished_at"/>
<result property="durationSec" column="duration_sec"/>
<result property="createdBy" column="created_by"/>
<result property="createdByName" column="created_by_name"/>
</resultMap>
<sql id="historyCols">
h.history_id, h.template_id, h.template_code,
t.template_name,
h.file_name, h.file_size, h.total_count, h.success_count, h.error_count,
h.skip_count, h.error_file_id, h.status, h.error_message,
h.started_at, h.finished_at, h.duration_sec, h.created_by,
u.login_id AS created_by_name
</sql>
<select id="selectList" parameterType="com.ga.core.vo.upload.UploadTemplateSearchParam"
resultMap="HistoryRespMap">
SELECT <include refid="historyCols"/>
FROM upload_history h
LEFT JOIN upload_template t ON t.template_id = h.template_id
LEFT JOIN users u ON u.user_id = h.created_by
<where>
<if test="searchKeyword != null and searchKeyword != ''">
AND (h.template_code LIKE CONCAT('%', #{searchKeyword}, '%')
OR h.file_name LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
<if test="status != null and status != ''">
AND h.status = #{status}
</if>
<if test="startDate != null and startDate != ''">
AND h.started_at >= #{startDate}::timestamp
</if>
<if test="endDate != null and endDate != ''">
AND h.started_at &lt; (#{endDate}::date + INTERVAL '1 day')
</if>
</where>
ORDER BY h.history_id DESC
</select>
<select id="selectByTemplate" resultMap="HistoryRespMap">
SELECT <include refid="historyCols"/>
FROM upload_history h
LEFT JOIN upload_template t ON t.template_id = h.template_id
LEFT JOIN users u ON u.user_id = h.created_by
WHERE h.template_id = #{templateId}
<if test="param.status != null and param.status != ''">
AND h.status = #{param.status}
</if>
ORDER BY h.history_id DESC
</select>
<insert id="insert" parameterType="com.ga.core.vo.upload.UploadHistoryVO"
useGeneratedKeys="true" keyProperty="historyId">
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}, #{status}, #{errorMessage}, #{startedAt}, #{finishedAt},
#{durationSec}, #{createdBy}
)
</insert>
<update id="updateStatus">
UPDATE upload_history SET
status = #{status},
error_message = #{errorMessage},
success_count = #{successCount},
error_count = #{errorCount},
skip_count = #{skipCount},
total_count = #{totalCount},
error_file_id = #{errorFileId},
duration_sec = #{durationSec},
finished_at = NOW()
WHERE history_id = #{historyId}
</update>
</mapper>
@@ -0,0 +1,141 @@
<?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.core.mapper.upload.UploadTemplateMapper">
<resultMap id="UploadTemplateVOMap" type="com.ga.core.vo.upload.UploadTemplateVO">
<id property="templateId" column="template_id"/>
<result property="templateCode" column="template_code"/>
<result property="templateName" column="template_name"/>
<result property="category" column="category"/>
<result property="targetTable" column="target_table"/>
<result property="companyCode" column="company_code"/>
<result property="headerRow" column="header_row"/>
<result property="dataStartRow" column="data_start_row"/>
<result property="sheetIndex" column="sheet_index"/>
<result property="sheetName" column="sheet_name"/>
<result property="fileEncoding" column="file_encoding"/>
<result property="delimiter" column="delimiter"/>
<result property="skipEmptyRow" column="skip_empty_row"/>
<result property="maxErrorCount" column="max_error_count"/>
<result property="batchSize" column="batch_size"/>
<result property="description" column="description"/>
<result property="isActive" column="is_active"/>
<result property="createdAt" column="created_at"/>
<result property="createdBy" column="created_by"/>
<result property="updatedAt" column="updated_at"/>
<result property="updatedBy" column="updated_by"/>
</resultMap>
<resultMap id="UploadTemplateRespMap" type="com.ga.core.vo.upload.UploadTemplateResp">
<id property="templateId" column="template_id"/>
<result property="templateCode" column="template_code"/>
<result property="templateName" column="template_name"/>
<result property="category" column="category"/>
<result property="targetTable" column="target_table"/>
<result property="companyCode" column="company_code"/>
<result property="headerRow" column="header_row"/>
<result property="dataStartRow" column="data_start_row"/>
<result property="sheetIndex" column="sheet_index"/>
<result property="sheetName" column="sheet_name"/>
<result property="fileEncoding" column="file_encoding"/>
<result property="delimiter" column="delimiter"/>
<result property="skipEmptyRow" column="skip_empty_row"/>
<result property="maxErrorCount" column="max_error_count"/>
<result property="batchSize" column="batch_size"/>
<result property="description" column="description"/>
<result property="isActive" column="is_active"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<sql id="templateCols">
t.template_id, t.template_code, t.template_name, t.category, t.target_table,
t.company_code, t.header_row, t.data_start_row, t.sheet_index, t.sheet_name,
t.file_encoding, t.delimiter, t.skip_empty_row, t.max_error_count, t.batch_size,
t.description, t.is_active, t.created_at, t.updated_at
</sql>
<select id="selectList" parameterType="com.ga.core.vo.upload.UploadTemplateSearchParam"
resultMap="UploadTemplateRespMap">
SELECT <include refid="templateCols"/>
FROM upload_template t
<where>
<if test="category != null and category != ''">
AND t.category = #{category}
</if>
<if test="companyCode != null and companyCode != ''">
AND t.company_code = #{companyCode}
</if>
<if test="isActive != null and isActive != ''">
AND t.is_active = #{isActive}
</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND (t.template_code LIKE CONCAT('%', #{searchKeyword}, '%')
OR t.template_name LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
</where>
ORDER BY t.template_id DESC
</select>
<select id="selectById" resultMap="UploadTemplateRespMap">
SELECT <include refid="templateCols"/>
FROM upload_template t
WHERE t.template_id = #{templateId}
</select>
<select id="selectByCode" resultMap="UploadTemplateVOMap">
SELECT t.template_id, t.template_code, t.template_name, t.category, t.target_table,
t.company_code, t.header_row, t.data_start_row, t.sheet_index, t.sheet_name,
t.file_encoding, t.delimiter, t.skip_empty_row, t.max_error_count, t.batch_size,
t.description, t.is_active, t.created_at, t.created_by, t.updated_at, t.updated_by
FROM upload_template t
WHERE t.template_code = #{templateCode}
</select>
<select id="existsByCode" resultType="int">
SELECT COUNT(*) FROM upload_template WHERE template_code = #{templateCode}
</select>
<insert id="insert" parameterType="com.ga.core.vo.upload.UploadTemplateVO"
useGeneratedKeys="true" keyProperty="templateId">
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_at, created_by
) VALUES (
#{templateCode}, #{templateName}, #{category}, #{targetTable}, #{companyCode},
#{headerRow}, #{dataStartRow}, #{sheetIndex}, #{sheetName}, #{fileEncoding},
#{delimiter}, #{skipEmptyRow}, #{maxErrorCount}, #{batchSize},
#{description}, #{isActive}, NOW(), #{createdBy}
)
</insert>
<update id="update" parameterType="com.ga.core.vo.upload.UploadTemplateVO">
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}
</update>
<delete id="delete">
DELETE FROM upload_template WHERE template_id = #{templateId}
</delete>
</mapper>