[agent-pipeline] pipe-20260327053504-cc40 round-2

This commit is contained in:
DDD1542
2026-03-27 18:11:56 +09:00
parent 12dd49fe3d
commit 3923dbefa0
398 changed files with 64367 additions and 386 deletions
@@ -0,0 +1,552 @@
package com.erp.service;
import com.erp.mapper.DdlMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Service
@Slf4j
public class DdlService {
private final DdlMapper ddlMapper;
private final JdbcTemplate jdbcTemplate;
private final TransactionTemplate transactionTemplate;
private static final Set<String> SYSTEM_TABLES = Set.of(
"user_info", "company_mng", "menu_info", "auth_group",
"table_labels", "column_labels", "screen_definitions", "screen_layouts",
"common_code", "multi_lang_key_master", "multi_lang_text",
"button_action_standards", "ddl_execution_log"
);
private static final Set<String> RESERVED_WORDS = Set.of(
"user", "order", "group", "table", "column", "index", "select", "insert",
"update", "delete", "from", "where", "join", "on", "as", "and", "or", "not",
"null", "true", "false", "create", "alter", "drop", "primary", "key",
"foreign", "references", "constraint", "default", "unique", "check",
"view", "procedure", "function"
);
private static final Set<String> RESERVED_COLUMNS = Set.of(
"id", "created_date", "updated_date", "company_code"
);
public DdlService(DdlMapper ddlMapper, JdbcTemplate jdbcTemplate,
PlatformTransactionManager transactionManager) {
this.ddlMapper = ddlMapper;
this.jdbcTemplate = jdbcTemplate;
this.transactionTemplate = new TransactionTemplate(transactionManager);
}
// ─────────────────────────────────────────────────────────────────────────
// CREATE TABLE
// ─────────────────────────────────────────────────────────────────────────
public Map<String, Object> createTable(String tableName, List<Map<String, Object>> columns,
String companyCode, String userId, String description) {
// 1. 검증
Map<String, Object> validation = validateTableCreation(tableName, columns);
if (!(Boolean) validation.get("isValid")) {
@SuppressWarnings("unchecked")
List<String> errors = (List<String>) validation.get("errors");
String errorMsg = "테이블 생성 검증 실패: " + String.join(", ", errors);
logDdlOperation(userId, companyCode, "CREATE_TABLE", tableName, "VALIDATION_FAILED", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "errorCode", "VALIDATION_FAILED");
}
// 2. 테이블 존재 여부 확인
if (tableExists(tableName)) {
String errorMsg = "테이블 '" + tableName + "'이 이미 존재합니다.";
logDdlOperation(userId, companyCode, "CREATE_TABLE", tableName, "TABLE_EXISTS", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "errorCode", "TABLE_EXISTS");
}
// 3. DDL 쿼리 생성
String ddlQuery = generateCreateTableQuery(tableName, columns);
// 4. 트랜잭션으로 DDL 실행 + 메타데이터 저장
try {
final String finalTableName = tableName;
final List<Map<String, Object>> finalColumns = columns;
final String finalCompanyCode = companyCode;
final String finalDescription = description;
transactionTemplate.execute(status -> {
jdbcTemplate.execute(ddlQuery);
saveTableMetadata(finalTableName, finalDescription);
saveColumnMetadata(finalTableName, finalColumns, finalCompanyCode);
return null;
});
// 5. 성공 로그 (트랜잭션 밖)
logDdlOperation(userId, companyCode, "CREATE_TABLE", tableName, ddlQuery, true, null);
log.info("테이블 생성 성공: {}, 사용자: {}, 컬럼수: {}", tableName, userId, columns.size());
return Map.of(
"success", true,
"message", "테이블 '" + tableName + "'이 성공적으로 생성되었습니다.",
"tableName", tableName,
"columnCount", columns.size(),
"executedQuery", ddlQuery
);
} catch (Exception e) {
String errorMsg = "테이블 생성 실패: " + e.getMessage();
logDdlOperation(userId, companyCode, "CREATE_TABLE", tableName,
"FAILED: " + e.getMessage(), false, errorMsg);
log.error("테이블 생성 실패: {}, 사용자: {}, 오류: {}", tableName, userId, e.getMessage(), e);
return Map.of("success", false, "message", errorMsg, "errorCode", "EXECUTION_FAILED");
}
}
// ─────────────────────────────────────────────────────────────────────────
// ADD COLUMN
// ─────────────────────────────────────────────────────────────────────────
public Map<String, Object> addColumn(String tableName, Map<String, Object> column,
String companyCode, String userId) {
// 1. 검증
List<String> errors = validateColumnForAddition(tableName, column);
if (!errors.isEmpty()) {
String errorMsg = "컬럼 추가 검증 실패: " + String.join(", ", errors);
logDdlOperation(userId, companyCode, "ADD_COLUMN", tableName, "VALIDATION_FAILED", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "errorCode", "VALIDATION_FAILED");
}
// 2. 테이블 존재 여부 확인
if (!tableExists(tableName)) {
String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다.";
logDdlOperation(userId, companyCode, "ADD_COLUMN", tableName, "TABLE_NOT_EXISTS", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "errorCode", "TABLE_NOT_EXISTS");
}
// 3. 컬럼 존재 여부 확인
String colName = (String) column.get("name");
if (columnExists(tableName, colName)) {
String errorMsg = "컬럼 '" + colName + "'이 이미 존재합니다.";
logDdlOperation(userId, companyCode, "ADD_COLUMN", tableName, "COLUMN_EXISTS", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "errorCode", "COLUMN_EXISTS");
}
// 4. DDL 쿼리 생성
String ddlQuery = generateAddColumnQuery(tableName, column);
// 5. 트랜잭션으로 DDL 실행 + 메타데이터 저장
try {
transactionTemplate.execute(status -> {
jdbcTemplate.execute(ddlQuery);
String inputType = convertToInputType(column);
String detailSettings = column.containsKey("detailSettings")
? column.get("detailSettings").toString() : "{}";
Integer maxOrder = jdbcTemplate.queryForObject(
"SELECT COALESCE(MAX(display_order), 0) FROM table_type_columns " +
"WHERE table_name = ? AND company_code = ?",
Integer.class, tableName, companyCode);
saveColumnMeta(tableName, colName, companyCode, inputType,
detailSettings, (maxOrder != null ? maxOrder : 0) + 1);
return null;
});
// 6. 성공 로그 (트랜잭션 밖)
logDdlOperation(userId, companyCode, "ADD_COLUMN", tableName, ddlQuery, true, null);
log.info("컬럼 추가 성공: {}.{}, 사용자: {}", tableName, colName, userId);
return Map.of(
"success", true,
"message", "컬럼 '" + colName + "'이 성공적으로 추가되었습니다.",
"tableName", tableName,
"columnName", colName,
"executedQuery", ddlQuery
);
} catch (Exception e) {
String errorMsg = "컬럼 추가 실패: " + e.getMessage();
logDdlOperation(userId, companyCode, "ADD_COLUMN", tableName,
"FAILED: " + e.getMessage(), false, errorMsg);
log.error("컬럼 추가 실패: {}.{}, 사용자: {}, 오류: {}", tableName, colName, userId, e.getMessage(), e);
return Map.of("success", false, "message", errorMsg, "errorCode", "EXECUTION_FAILED");
}
}
// ─────────────────────────────────────────────────────────────────────────
// DROP TABLE
// ─────────────────────────────────────────────────────────────────────────
public Map<String, Object> dropTable(String tableName, String companyCode, String userId) {
// 1. 시스템 테이블 보호
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
String errorMsg = "'" + tableName + "'은 시스템 테이블이므로 삭제할 수 없습니다.";
logDdlOperation(userId, companyCode, "DROP_TABLE", tableName,
"SYSTEM_TABLE_PROTECTED", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "errorCode", "SYSTEM_TABLE_PROTECTED");
}
// 2. 테이블 존재 여부 확인
if (!tableExists(tableName)) {
String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다.";
logDdlOperation(userId, companyCode, "DROP_TABLE", tableName, "TABLE_NOT_FOUND", false, errorMsg);
return Map.of("success", false, "message", errorMsg, "errorCode", "TABLE_NOT_FOUND");
}
String ddlQuery = "DROP TABLE IF EXISTS \"" + sanitize(tableName) + "\" CASCADE";
try {
transactionTemplate.execute(status -> {
jdbcTemplate.execute(ddlQuery);
jdbcTemplate.update("DELETE FROM table_type_columns WHERE table_name = ?", tableName);
jdbcTemplate.update("DELETE FROM table_labels WHERE table_name = ?", tableName);
return null;
});
logDdlOperation(userId, companyCode, "DROP_TABLE", tableName, ddlQuery, true, null);
log.info("테이블 삭제 성공: {}, 사용자: {}", tableName, userId);
return Map.of(
"success", true,
"message", "테이블 '" + tableName + "'이 성공적으로 삭제되었습니다.",
"tableName", tableName,
"executedQuery", ddlQuery
);
} catch (Exception e) {
String errorMsg = "테이블 삭제 실패: " + e.getMessage();
logDdlOperation(userId, companyCode, "DROP_TABLE", tableName,
"FAILED: " + e.getMessage(), false, errorMsg);
log.error("테이블 삭제 실패: {}, 사용자: {}, 오류: {}", tableName, userId, e.getMessage(), e);
return Map.of("success", false, "message", errorMsg, "errorCode", "EXECUTION_FAILED");
}
}
// ─────────────────────────────────────────────────────────────────────────
// VALIDATE
// ─────────────────────────────────────────────────────────────────────────
public Map<String, Object> validateTableCreation(String tableName, List<Map<String, Object>> columns) {
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
errors.addAll(validateTableName(tableName));
if (columns == null || columns.isEmpty()) {
errors.add("최소 1개의 컬럼이 필요합니다.");
} else {
Set<String> seen = new LinkedHashSet<>();
Set<String> duplicates = new LinkedHashSet<>();
for (int i = 0; i < columns.size(); i++) {
Map<String, Object> col = columns.get(i);
errors.addAll(validateSingleColumn(col, i + 1));
String colName = col.get("name") != null ? col.get("name").toString().toLowerCase() : "";
if (!colName.isEmpty() && !seen.add(colName)) {
duplicates.add(colName);
}
}
if (!duplicates.isEmpty()) {
errors.add("중복된 컬럼명이 있습니다: " + String.join(", ", duplicates));
}
}
int colCount = columns != null ? columns.size() : 0;
return Map.of(
"isValid", errors.isEmpty(),
"errors", errors,
"warnings", warnings,
"summary", buildValidationSummary(tableName, colCount, errors, warnings)
);
}
// ─────────────────────────────────────────────────────────────────────────
// LOG QUERIES
// ─────────────────────────────────────────────────────────────────────────
public List<Map<String, Object>> getDdlLogs(int limit, String userId, String ddlType) {
Map<String, Object> params = new HashMap<>();
params.put("limit", Math.min(limit, 200));
params.put("userId", userId);
params.put("ddlType", ddlType);
return ddlMapper.selectDdlLogs(params);
}
public Map<String, Object> getDdlStatistics(String fromDate, String toDate) {
Map<String, Object> params = new HashMap<>();
params.put("fromDate", fromDate);
params.put("toDate", toDate);
Map<String, Object> totalStats = ddlMapper.selectDdlTotalStats(params);
List<Map<String, Object>> byType = ddlMapper.selectDdlStatsByType(params);
List<Map<String, Object>> byUser = ddlMapper.selectDdlStatsByUser(params);
List<Map<String, Object>> recentFailures = ddlMapper.selectRecentFailures(params);
Map<String, Long> byDdlType = new LinkedHashMap<>();
for (Map<String, Object> row : byType) {
byDdlType.put(String.valueOf(row.get("ddlType")), toLong(row.get("count")));
}
Map<String, Long> byUserMap = new LinkedHashMap<>();
for (Map<String, Object> row : byUser) {
byUserMap.put(String.valueOf(row.get("userId")), toLong(row.get("count")));
}
return Map.of(
"totalExecutions", toLong(totalStats != null ? totalStats.get("totalExecutions") : null),
"successfulExecutions", toLong(totalStats != null ? totalStats.get("successfulExecutions") : null),
"failedExecutions", toLong(totalStats != null ? totalStats.get("failedExecutions") : null),
"byDDLType", byDdlType,
"byUser", byUserMap,
"recentFailures", recentFailures
);
}
public List<Map<String, Object>> getTableDdlHistory(String tableName) {
Map<String, Object> params = new HashMap<>();
params.put("tableName", tableName);
return ddlMapper.selectTableDdlHistory(params);
}
public Map<String, Object> getTableInfo(String tableName) {
Map<String, Object> params = new HashMap<>();
params.put("tableName", tableName);
Map<String, Object> tableInfo = ddlMapper.selectTableInfo(params);
if (tableInfo == null) return null;
List<Map<String, Object>> columns = ddlMapper.selectTableColumns(params);
return Map.of("tableInfo", tableInfo, "columns", columns);
}
public int cleanupOldLogs(int retentionDays) {
LocalDateTime cutoff = LocalDateTime.now().minusDays(retentionDays);
Map<String, Object> params = new HashMap<>();
params.put("cutoffDate", cutoff.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
int deleted = ddlMapper.deleteOldDdlLogs(params);
log.info("DDL 로그 정리 완료: {}개 삭제, 보존 기간: {}일", deleted, retentionDays);
return deleted;
}
// ─────────────────────────────────────────────────────────────────────────
// DDL QUERY GENERATION
// ─────────────────────────────────────────────────────────────────────────
private String generateCreateTableQuery(String tableName, List<Map<String, Object>> columns) {
StringBuilder sb = new StringBuilder();
sb.append("CREATE TABLE \"").append(sanitize(tableName)).append("\" (\n");
sb.append(" \"id\" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,\n");
sb.append(" \"created_date\" timestamp DEFAULT now(),\n");
sb.append(" \"updated_date\" timestamp DEFAULT now(),\n");
sb.append(" \"writer\" varchar(500) DEFAULT NULL,\n");
sb.append(" \"company_code\" varchar(500)");
for (Map<String, Object> col : columns) {
sb.append(",\n \"").append(sanitize((String) col.get("name"))).append("\" varchar(500)");
Boolean nullable = col.containsKey("nullable") ? (Boolean) col.get("nullable") : true;
if (Boolean.FALSE.equals(nullable)) sb.append(" NOT NULL");
if (col.get("defaultValue") != null && !col.get("defaultValue").toString().isBlank()) {
sb.append(" DEFAULT '").append(col.get("defaultValue").toString().replace("'", "''")).append("'");
}
}
sb.append("\n);");
return sb.toString();
}
private String generateAddColumnQuery(String tableName, Map<String, Object> column) {
StringBuilder sb = new StringBuilder();
sb.append("ALTER TABLE \"").append(sanitize(tableName)).append("\" ADD COLUMN ");
sb.append("\"").append(sanitize((String) column.get("name"))).append("\" varchar(500)");
Boolean nullable = column.containsKey("nullable") ? (Boolean) column.get("nullable") : true;
if (Boolean.FALSE.equals(nullable)) sb.append(" NOT NULL");
if (column.get("defaultValue") != null && !column.get("defaultValue").toString().isBlank()) {
sb.append(" DEFAULT '").append(column.get("defaultValue").toString().replace("'", "''")).append("'");
}
sb.append(";");
return sb.toString();
}
// ─────────────────────────────────────────────────────────────────────────
// TABLE / COLUMN EXISTS
// ─────────────────────────────────────────────────────────────────────────
private boolean tableExists(String tableName) {
Integer cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema = 'public' AND table_name = ?",
Integer.class, tableName);
return cnt != null && cnt > 0;
}
private boolean columnExists(String tableName, String columnName) {
Integer cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema = 'public' AND table_name = ? AND column_name = ?",
Integer.class, tableName, columnName);
return cnt != null && cnt > 0;
}
// ─────────────────────────────────────────────────────────────────────────
// METADATA SAVE
// ─────────────────────────────────────────────────────────────────────────
private void saveTableMetadata(String tableName, String description) {
String desc = description != null && !description.isBlank()
? description : "사용자 생성 테이블: " + tableName;
jdbcTemplate.update(
"INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) " +
"VALUES (?, ?, ?, now(), now()) " +
"ON CONFLICT (table_name) DO UPDATE SET table_label = ?, description = ?, updated_date = now()",
tableName, tableName, desc, tableName, desc);
}
private void saveColumnMetadata(String tableName, List<Map<String, Object>> columns, String companyCode) {
// 기본 시스템 컬럼
List<Object[]> defaultCols = List.of(
new Object[]{"id", "text", -5},
new Object[]{"created_date", "date", -4},
new Object[]{"updated_date", "date", -3},
new Object[]{"writer", "text", -2},
new Object[]{"company_code", "text", -1}
);
for (Object[] dc : defaultCols) {
saveColumnMeta(tableName, (String) dc[0], companyCode, (String) dc[1], "{}", (int) dc[2]);
}
// 사용자 정의 컬럼
for (int i = 0; i < columns.size(); i++) {
Map<String, Object> col = columns.get(i);
String detailSettings = col.containsKey("detailSettings")
? col.get("detailSettings").toString() : "{}";
saveColumnMeta(tableName, (String) col.get("name"), companyCode,
convertToInputType(col), detailSettings, i);
}
}
private void saveColumnMeta(String tableName, String colName, String companyCode,
String inputType, String detailSettings, int order) {
jdbcTemplate.update(
"INSERT INTO table_type_columns " +
" (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date) " +
"VALUES (?, ?, ?, ?, ?::jsonb, 'Y', ?, now(), now()) " +
"ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET " +
" input_type = ?, detail_settings = ?::jsonb, display_order = ?, updated_date = now()",
tableName, colName, companyCode, inputType, detailSettings, order,
inputType, detailSettings, order);
}
// ─────────────────────────────────────────────────────────────────────────
// VALIDATION HELPERS
// ─────────────────────────────────────────────────────────────────────────
private List<String> validateTableName(String tableName) {
List<String> errors = new ArrayList<>();
if (tableName == null || tableName.isBlank()) {
errors.add("테이블명은 필수입니다.");
return errors;
}
if (!tableName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
errors.add("유효하지 않은 테이블명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.");
}
if (tableName.length() > 63) errors.add("테이블명은 63자를 초과할 수 없습니다.");
if (tableName.length() < 2) errors.add("테이블명은 최소 2자 이상이어야 합니다.");
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
errors.add("'" + tableName + "'은 시스템 테이블명으로 사용할 수 없습니다.");
}
if (RESERVED_WORDS.contains(tableName.toLowerCase())) {
errors.add("'" + tableName + "'은 SQL 예약어이므로 테이블명으로 사용할 수 없습니다.");
}
if (tableName.startsWith("_") || tableName.endsWith("_")) {
errors.add("테이블명은 언더스코어로 시작하거나 끝날 수 없습니다.");
}
if (tableName.contains("__")) {
errors.add("테이블명에 연속된 언더스코어는 사용할 수 없습니다.");
}
return errors;
}
private List<String> validateSingleColumn(Map<String, Object> column, int position) {
List<String> errors = new ArrayList<>();
String name = column.get("name") != null ? column.get("name").toString().trim() : "";
String prefix = "컬럼 " + position + "(" + name + "): ";
if (name.isBlank()) {
errors.add(prefix + "컬럼명은 필수입니다.");
return errors;
}
if (!name.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
errors.add(prefix + "유효하지 않은 컬럼명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.");
}
if (name.length() > 63) errors.add(prefix + "컬럼명은 63자를 초과할 수 없습니다.");
if (name.length() < 2) errors.add(prefix + "컬럼명은 최소 2자 이상이어야 합니다.");
if (RESERVED_COLUMNS.contains(name.toLowerCase())) {
errors.add(prefix + "'" + name + "'은 예약된 컬럼명입니다 (id, created_date, updated_date, company_code).");
}
if (RESERVED_WORDS.contains(name.toLowerCase())) {
errors.add(prefix + "'" + name + "'은 SQL 예약어이므로 컬럼명으로 사용할 수 없습니다.");
}
if (name.contains("__")) {
errors.add(prefix + "컬럼명에 연속된 언더스코어는 사용할 수 없습니다.");
}
return errors;
}
private List<String> validateColumnForAddition(String tableName, Map<String, Object> column) {
List<String> errors = new ArrayList<>();
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
errors.add("'" + tableName + "'은 시스템 테이블이므로 컬럼을 추가할 수 없습니다.");
}
errors.addAll(validateSingleColumn(column, 1));
return errors;
}
// ─────────────────────────────────────────────────────────────────────────
// UTILITIES
// ─────────────────────────────────────────────────────────────────────────
private String sanitize(String name) {
return name.replaceAll("[^a-zA-Z0-9_]", "");
}
private String convertToInputType(Map<String, Object> col) {
Object inputType = col.get("inputType");
Object webType = col.get("webType");
String type = inputType != null ? inputType.toString()
: (webType != null ? webType.toString() : "text");
return switch (type) {
case "number", "decimal" -> "number";
case "date", "datetime" -> "date";
case "select", "dropdown" -> "select";
case "checkbox", "boolean" -> "checkbox";
case "radio" -> "radio";
case "code" -> "code";
case "entity" -> "entity";
default -> "text";
};
}
private void logDdlOperation(String userId, String companyCode, String ddlType,
String tableName, String ddlQuery, boolean success, String errorMessage) {
try {
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("companyCode", companyCode);
params.put("ddlType", ddlType);
params.put("tableName", tableName);
params.put("ddlQuery", ddlQuery);
params.put("success", success);
params.put("errorMessage", errorMessage);
ddlMapper.insertDdlLog(params);
} catch (Exception e) {
log.error("DDL 로그 기록 실패: userId={}, ddlType={}, tableName={}", userId, ddlType, tableName, e);
}
}
private long toLong(Object value) {
if (value == null) return 0L;
if (value instanceof Number) return ((Number) value).longValue();
try { return Long.parseLong(value.toString()); } catch (NumberFormatException ex) { return 0L; }
}
private String buildValidationSummary(String tableName, int columnCount,
List<String> errors, List<String> warnings) {
StringBuilder sb = new StringBuilder("테이블 '").append(tableName).append("' 검증 완료. ");
sb.append("컬럼 ").append(columnCount).append("개 중 ");
sb.append(errors.isEmpty() ? "모든 검증 통과." : errors.size() + "개 오류 발견.");
if (!warnings.isEmpty()) sb.append(" ").append(warnings.size()).append("개 경고 있음.");
return sb.toString();
}
}