1e1b3e103c
이전 PR (#15) 의 Java parseColumnArray 분기 추가가 실제 운영 환경에서 빈 배열을 그대로 반환 — MyBatis ↔ PostgreSQL JDBC 의 array 타입 변환이 java.sql.Array 가 아닌 다른 경로로 도착하는 듯. 방식 변경: SQL 단에서 ARRAY_AGG(...)::text 캐스트 → PostgreSQL 가 "{email,phone}" String 으로 반환. parseColumnArray 의 기존 String 분기 (중괄호 제거 + 쉼표 split) 가 자연스럽게 처리. 장점: - JDBC 드라이버 / MyBatis 변환 동작에 의존하지 않음 - parseColumnArray 코드 단순 복원 (List/String 2분기) - 한 줄 SQL 변경으로 PK/IDX 두 쿼리 모두 해결 검증: - gradle compileJava BUILD SUCCESSFUL - solution.invyone.com 에서 customer_mng PK columns / email IDX columns 비어있지 않음 확인 예정 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
985 lines
47 KiB
Java
985 lines
47 KiB
Java
package com.erp.service;
|
|
|
|
import com.erp.common.BaseService;
|
|
import com.erp.constants.InputTypeConstants;
|
|
import com.erp.constants.InputTypeContext;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.util.*;
|
|
import java.util.stream.Collectors;
|
|
|
|
/**
|
|
* 테이블 관리 서비스.
|
|
* - 정적 메타데이터 쿼리 → sqlSession (MyBatis XML)
|
|
* - 동적 테이블 CRUD (임의 테이블/컬럼) → JdbcTemplate (런타임 SQL 빌드 불가피)
|
|
*/
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
@Slf4j
|
|
public class TableManagementService extends BaseService {
|
|
|
|
private final JdbcTemplate jdbcTemplate;
|
|
private final ObjectMapper objectMapper;
|
|
|
|
private static final String NS = "tableManagement.";
|
|
|
|
/** 로그 테이블 컬럼 정의에 허용하는 PostgreSQL data_type 화이트리스트.
|
|
* information_schema.columns.data_type 값과 정확히 일치해야 한다. */
|
|
private static final Set<String> ALLOWED_LOG_COLUMN_TYPES = Set.of(
|
|
"varchar", "text", "char", "character", "character varying",
|
|
"integer", "bigint", "smallint", "numeric", "decimal", "real", "double precision",
|
|
"boolean", "date", "timestamp", "timestamp without time zone", "timestamp with time zone",
|
|
"time", "time without time zone", "time with time zone",
|
|
"uuid", "json", "jsonb", "bytea"
|
|
);
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 테이블 목록
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public List<Map<String, Object>> getTableList() {
|
|
List<Map<String, Object>> tables = sqlSession.selectList(NS + "getTableList");
|
|
// columnCount Long → Integer 변환 (Node 호환)
|
|
for (Map<String, Object> t : tables) {
|
|
Object cnt = t.get("column_count");
|
|
if (cnt instanceof Number) {
|
|
t.put("column_count", ((Number) cnt).intValue());
|
|
}
|
|
}
|
|
return tables;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 컬럼 목록
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> getColumnList(String tableName, int page, int size, String companyCode) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("size", size);
|
|
params.put("offset", (long) (page - 1) * size);
|
|
|
|
Integer totalObj = sqlSession.selectOne(NS + "getColumnListCnt", params);
|
|
int total = totalObj != null ? totalObj : 0;
|
|
List<Map<String, Object>> columns;
|
|
|
|
if (companyCode != null && !companyCode.isBlank()) {
|
|
params.put("company_code", companyCode);
|
|
columns = sqlSession.selectList(NS + "getColumnListWithCompany", params);
|
|
} else {
|
|
columns = sqlSession.selectList(NS + "getColumnList", params);
|
|
}
|
|
|
|
int totalPages = total == 0 ? 1 : (int) Math.ceil((double) total / size);
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
result.put("columns", columns);
|
|
result.put("total", total);
|
|
result.put("page", page);
|
|
result.put("size", size);
|
|
result.put("total_pages", totalPages);
|
|
return result;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 테이블 스키마 / 존재 여부
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public List<Map<String, Object>> getTableSchema(String tableName) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
return sqlSession.selectList(NS + "getTableSchemaList", params);
|
|
}
|
|
|
|
public boolean checkTableExists(String tableName) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
Map<String, Object> result = sqlSession.selectOne(NS + "checkTableExists", params);
|
|
Object exists = result != null ? result.get("exists") : null;
|
|
return Boolean.TRUE.equals(exists);
|
|
}
|
|
|
|
public boolean hasColumn(String tableName, String columnName) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", sanitize(tableName));
|
|
params.put("column_name", sanitize(columnName));
|
|
Integer cntObj = sqlSession.selectOne(NS + "checkTableHasColumn", params);
|
|
return cntObj != null && cntObj > 0;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 테이블 라벨
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> getTableLabels(String tableName) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
return sqlSession.selectOne(NS + "getTableLabelInfo", params);
|
|
}
|
|
|
|
@Transactional
|
|
public void updateTableLabel(String tableName, String displayName, String description) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("display_name", displayName);
|
|
params.put("description", description != null ? description : "");
|
|
sqlSession.update(NS + "upsertTableLabel", params);
|
|
log.info("테이블 라벨 업데이트: {}", tableName);
|
|
}
|
|
|
|
private void ensureTableInLabels(String tableName) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
sqlSession.insert(NS + "insertTableLabelIfNotExists", params);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 컬럼 라벨
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> getColumnLabels(String tableName, String columnName) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("column_name", columnName);
|
|
return sqlSession.selectOne(NS + "getColumnLabelInfo", params);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 컬럼 설정 업데이트
|
|
// ──────────────────────────────────────────────────
|
|
|
|
@Transactional
|
|
public void updateColumnSettings(String tableName, String columnName,
|
|
Map<String, Object> settings, String companyCode) {
|
|
ensureTableInLabels(tableName);
|
|
|
|
Object rawInputType = settings.get("input_type");
|
|
boolean inputTypeChanged = settings.containsKey("input_type") && rawInputType != null;
|
|
InputTypeContext ctx = inputTypeChanged
|
|
? InputTypeContext.USER_UPDATE_TYPE
|
|
: InputTypeContext.USER_UPDATE_OTHER;
|
|
String inputType = normalizeInputType((String) rawInputType, ctx);
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("column_name", columnName);
|
|
params.put("column_label", settings.get("column_label"));
|
|
params.put("input_type", inputType);
|
|
params.put("detail_settings", toJsonString(settings.get("detail_settings")));
|
|
params.put("code_category", "code".equals(inputType) ? settings.get("code_category") : null);
|
|
params.put("code_value", "code".equals(inputType) ? settings.get("code_value") : null);
|
|
params.put("reference_table", "entity".equals(inputType) ? settings.get("reference_table") : null);
|
|
params.put("reference_column", "entity".equals(inputType) ? settings.get("reference_column") : null);
|
|
params.put("display_column", "entity".equals(inputType) ? settings.get("display_column") : null);
|
|
params.put("display_order", settings.getOrDefault("display_order", 0));
|
|
params.put("is_visible", settings.getOrDefault("is_visible", true));
|
|
params.put("company_code", companyCode);
|
|
params.put("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null);
|
|
sqlSession.update(NS + "upsertColumnSettings", params);
|
|
|
|
// 화면 레이아웃 동기화
|
|
syncScreenLayouts(tableName, columnName, inputType, companyCode);
|
|
log.info("컬럼 설정 업데이트: {}.{}, company={}", tableName, columnName, companyCode);
|
|
}
|
|
|
|
@Transactional
|
|
public void updateAllColumnSettings(String tableName, List<Map<String, Object>> columnSettings,
|
|
String companyCode) {
|
|
ensureTableInLabels(tableName);
|
|
for (Map<String, Object> colSetting : columnSettings) {
|
|
Object colName = colSetting.get("column_name");
|
|
if (colName != null && !colName.toString().isBlank()) {
|
|
updateColumnSettings(tableName, colName.toString(), colSetting, companyCode);
|
|
}
|
|
}
|
|
log.info("컬럼 설정 일괄 업데이트: {}, {}개, company={}", tableName, columnSettings.size(), companyCode);
|
|
}
|
|
|
|
@Transactional
|
|
public void updateColumnWebType(String tableName, String columnName,
|
|
String webType, Map<String, Object> detailSettings) {
|
|
String finalType = normalizeInputType(webType);
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("column_name", columnName);
|
|
params.put("input_type", finalType);
|
|
params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}");
|
|
params.put("company_code", "*");
|
|
params.put("clear_entity", false);
|
|
params.put("clear_code", false);
|
|
params.put("clear_category", false);
|
|
sqlSession.update(NS + "upsertColumnInputType", params);
|
|
log.info("컬럼 웹타입 설정: {}.{} = {}", tableName, columnName, finalType);
|
|
}
|
|
|
|
@Transactional
|
|
public void updateColumnInputType(String tableName, String columnName,
|
|
String inputType, String companyCode,
|
|
Map<String, Object> detailSettings) {
|
|
String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE);
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("column_name", columnName);
|
|
params.put("input_type", finalType);
|
|
params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}");
|
|
params.put("company_code", companyCode);
|
|
params.put("clear_entity", !"entity".equals(finalType));
|
|
params.put("clear_code", !"code".equals(finalType));
|
|
params.put("clear_category", !"category".equals(finalType));
|
|
sqlSession.update(NS + "upsertColumnInputType", params);
|
|
syncScreenLayouts(tableName, columnName, finalType, companyCode);
|
|
log.info("컬럼 입력타입 설정: {}.{} = {}, company={}", tableName, columnName, finalType, companyCode);
|
|
}
|
|
|
|
public List<Map<String, Object>> getColumnInputTypes(String tableName, String companyCode) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("company_code", companyCode);
|
|
return sqlSession.selectList(NS + "getColumnInputTypeList", params);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 제약조건 관리
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> getTableConstraints(String tableName) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
|
|
List<Map<String, Object>> pkResult = sqlSession.selectList(NS + "getTablePrimaryKeyList", params);
|
|
Map<String, Object> primaryKey = new HashMap<>();
|
|
if (!pkResult.isEmpty()) {
|
|
Map<String, Object> pk = pkResult.get(0);
|
|
primaryKey.put("name", pk.get("constraint_name"));
|
|
primaryKey.put("columns", parseColumnArray(pk.get("columns")));
|
|
} else {
|
|
primaryKey.put("name", "");
|
|
primaryKey.put("columns", List.of());
|
|
}
|
|
|
|
List<Map<String, Object>> indexResult = sqlSession.selectList(NS + "getTableIndexList", params);
|
|
List<Map<String, Object>> indexes = indexResult.stream().map(row -> {
|
|
Map<String, Object> idx = new HashMap<>();
|
|
idx.put("name", row.get("index_name"));
|
|
idx.put("columns", parseColumnArray(row.get("columns")));
|
|
idx.put("is_unique", row.get("is_unique"));
|
|
return idx;
|
|
}).collect(Collectors.toList());
|
|
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("primary_key", primaryKey);
|
|
result.put("indexes", indexes);
|
|
return result;
|
|
}
|
|
|
|
@Transactional
|
|
public void setTablePrimaryKey(String tableName, List<String> columns) {
|
|
String safeTable = sanitize(tableName);
|
|
// 기존 PK 삭제
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", safeTable);
|
|
List<Map<String, Object>> existingPk = sqlSession.selectList(NS + "getTablePrimaryKeyList", params);
|
|
if (!existingPk.isEmpty()) {
|
|
String constraintName = (String) existingPk.get(0).get("constraint_name");
|
|
jdbcTemplate.execute(
|
|
String.format("ALTER TABLE \"public\".\"%s\" DROP CONSTRAINT \"%s\"",
|
|
safeTable, sanitize(constraintName)));
|
|
log.info("기존 PK 삭제: {}.{}", safeTable, constraintName);
|
|
}
|
|
// 새 PK 추가
|
|
String colList = columns.stream()
|
|
.map(c -> "\"" + sanitize(c) + "\"")
|
|
.collect(Collectors.joining(", "));
|
|
jdbcTemplate.execute(
|
|
String.format("ALTER TABLE \"public\".\"%s\" ADD PRIMARY KEY (%s)", safeTable, colList));
|
|
log.info("PK 설정: {} → [{}]", safeTable, String.join(", ", columns));
|
|
}
|
|
|
|
@Transactional
|
|
public void toggleTableIndex(String tableName, String columnName,
|
|
String indexType, String action) {
|
|
String safeTable = sanitize(tableName);
|
|
String safeCol = sanitize(columnName);
|
|
String indexName = String.format("idx_%s_%s%s", safeTable, safeCol,
|
|
"unique".equals(indexType) ? "_uq" : "");
|
|
|
|
if ("create".equals(action)) {
|
|
String indexColumns = "\"" + safeCol + "\"";
|
|
if ("unique".equals(indexType) && hasColumn(safeTable, "company_code")) {
|
|
indexColumns = "\"company_code\", \"" + safeCol + "\"";
|
|
log.info("멀티테넌시: company_code + {} 복합 유니크 인덱스", safeCol);
|
|
}
|
|
String unique = "unique".equals(indexType) ? "UNIQUE " : "";
|
|
jdbcTemplate.execute(
|
|
String.format("CREATE %sINDEX IF NOT EXISTS \"%s\" ON \"public\".\"%s\" (%s)",
|
|
unique, indexName, safeTable, indexColumns));
|
|
log.info("인덱스 생성: {}", indexName);
|
|
} else if ("drop".equals(action)) {
|
|
jdbcTemplate.execute(
|
|
String.format("DROP INDEX IF EXISTS \"public\".\"%s\"", indexName));
|
|
log.info("인덱스 삭제: {}", indexName);
|
|
}
|
|
}
|
|
|
|
@Transactional
|
|
public void toggleColumnNullable(String tableName, String columnName,
|
|
boolean nullable, String companyCode) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("column_name", columnName);
|
|
params.put("is_nullable", nullable ? "Y" : "N");
|
|
params.put("company_code", companyCode);
|
|
sqlSession.update(NS + "upsertNullable", params);
|
|
log.info("NOT NULL 토글: {}.{} nullable={}, company={}", tableName, columnName, nullable, companyCode);
|
|
}
|
|
|
|
@Transactional
|
|
public void toggleColumnUnique(String tableName, String columnName,
|
|
boolean unique, String companyCode) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("column_name", columnName);
|
|
params.put("is_unique", unique ? "Y" : "N");
|
|
params.put("company_code", companyCode);
|
|
sqlSession.update(NS + "upsertUnique", params);
|
|
log.info("UNIQUE 토글: {}.{} unique={}, company={}", tableName, columnName, unique, companyCode);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 소프트 제약조건 검증
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public List<String> validateNotNullConstraints(String tableName,
|
|
Map<String, Object> data,
|
|
String companyCode) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("company_code", companyCode);
|
|
List<Map<String, Object>> notNullCols = sqlSession.selectList(NS + "getNotNullColumnList", params);
|
|
|
|
List<String> violations = new ArrayList<>();
|
|
for (Map<String, Object> col : notNullCols) {
|
|
String colName = (String) col.get("column_name");
|
|
Object val = data.get(colName);
|
|
if (val == null || val.toString().isBlank()) {
|
|
violations.add(colName);
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
public List<String> validateUniqueConstraints(String tableName,
|
|
Map<String, Object> data,
|
|
String companyCode,
|
|
String excludeId) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("company_code", companyCode);
|
|
List<Map<String, Object>> uniqueCols = sqlSession.selectList(NS + "getUniqueColumnList", params);
|
|
|
|
String safeTable = sanitize(tableName);
|
|
List<String> violations = new ArrayList<>();
|
|
|
|
for (Map<String, Object> col : uniqueCols) {
|
|
String colName = (String) col.get("column_name");
|
|
Object val = data.get(colName);
|
|
if (val == null) continue;
|
|
|
|
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
|
|
String sql;
|
|
List<Object> sqlParams = new ArrayList<>();
|
|
|
|
if (hasCompanyCode && !"*".equals(companyCode)) {
|
|
sql = String.format(
|
|
"SELECT COUNT(*) FROM \"%s\" WHERE \"%s\" = ? AND company_code = ?%s",
|
|
safeTable, sanitize(colName),
|
|
excludeId != null ? " AND id::text != ?" : "");
|
|
sqlParams.add(val.toString());
|
|
sqlParams.add(companyCode);
|
|
if (excludeId != null) sqlParams.add(excludeId);
|
|
} else {
|
|
sql = String.format(
|
|
"SELECT COUNT(*) FROM \"%s\" WHERE \"%s\" = ?%s",
|
|
safeTable, sanitize(colName),
|
|
excludeId != null ? " AND id::text != ?" : "");
|
|
sqlParams.add(val.toString());
|
|
if (excludeId != null) sqlParams.add(excludeId);
|
|
}
|
|
|
|
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, sqlParams.toArray());
|
|
if (count != null && count > 0) {
|
|
violations.add(colName);
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 동적 테이블 CRUD (JdbcTemplate 사용 - 임의 테이블/컬럼이므로 MyBatis XML 불가)
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> getTableData(String tableName, Map<String, Object> options) {
|
|
String safeTable = sanitize(tableName);
|
|
int page = toInt(options.get("page"), 1);
|
|
int size = toInt(options.get("size"), 10);
|
|
int offset = (page - 1) * size;
|
|
@SuppressWarnings("unchecked")
|
|
Map<String, Object> search = (Map<String, Object>) options.getOrDefault("search", Map.of());
|
|
String sortBy = (String) options.get("sort_by");
|
|
String sortOrder = "desc".equalsIgnoreCase((String) options.get("sort_order")) ? "DESC" : "ASC";
|
|
|
|
List<String> conditions = new ArrayList<>();
|
|
List<Object> values = new ArrayList<>();
|
|
|
|
for (Map.Entry<String, Object> entry : search.entrySet()) {
|
|
Object val = entry.getValue();
|
|
if (val == null || val.toString().isBlank() || "__ALL__".equals(val.toString())) continue;
|
|
String safeCol = sanitize(entry.getKey());
|
|
if (safeCol.isBlank()) continue;
|
|
conditions.add(String.format("\"%s\"::text ILIKE ?", safeCol));
|
|
values.add("%" + val.toString() + "%");
|
|
}
|
|
|
|
String where = conditions.isEmpty() ? "" : "WHERE " + String.join(" AND ", conditions);
|
|
|
|
// 정렬
|
|
String order = "";
|
|
if (sortBy != null && !sortBy.isBlank()) {
|
|
order = String.format("ORDER BY \"%s\" %s", sanitize(sortBy), sortOrder);
|
|
} else if (hasColumn(safeTable, "created_date")) {
|
|
order = "ORDER BY main.created_date DESC";
|
|
}
|
|
|
|
String countSql = String.format("SELECT COUNT(*) FROM \"%s\" main %s", safeTable, where);
|
|
Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, values.toArray());
|
|
if (total == null) total = 0;
|
|
|
|
String dataSql = String.format(
|
|
"SELECT main.* FROM \"%s\" main %s %s LIMIT %d OFFSET %d",
|
|
safeTable, where, order, size, offset);
|
|
List<Map<String, Object>> data = jdbcTemplate.queryForList(dataSql, values.toArray());
|
|
|
|
int totalPages = total == 0 ? 1 : (int) Math.ceil((double) total / size);
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
result.put("data", data);
|
|
result.put("total", total);
|
|
result.put("page", page);
|
|
result.put("size", size);
|
|
result.put("total_pages", totalPages);
|
|
return result;
|
|
}
|
|
|
|
@Transactional
|
|
public Map<String, Object> addTableData(String tableName, Map<String, Object> data) {
|
|
String safeTable = sanitize(tableName);
|
|
|
|
// 테이블 컬럼 정보 조회
|
|
Map<String, String> colTypes = getColumnTypes(safeTable);
|
|
|
|
// created_date 자동 설정
|
|
if (colTypes.containsKey("created_date") && !data.containsKey("created_date")) {
|
|
data.put("created_date", new java.sql.Timestamp(System.currentTimeMillis()));
|
|
}
|
|
|
|
// 존재하는 컬럼만 필터링
|
|
List<String> existingCols = new ArrayList<>();
|
|
for (String col : data.keySet()) {
|
|
if (colTypes.containsKey(col)) existingCols.add(col);
|
|
}
|
|
|
|
if (existingCols.isEmpty()) {
|
|
throw new IllegalArgumentException("저장할 유효한 컬럼이 없습니다. 테이블: " + safeTable);
|
|
}
|
|
|
|
String colNames = existingCols.stream()
|
|
.map(c -> "\"" + sanitize(c) + "\"")
|
|
.collect(Collectors.joining(", "));
|
|
String placeholders = existingCols.stream().map(c -> "?").collect(Collectors.joining(", "));
|
|
Object[] params = existingCols.stream().map(data::get).toArray();
|
|
|
|
String sql = String.format(
|
|
"INSERT INTO \"%s\" (%s) VALUES (%s)", safeTable, colNames, placeholders);
|
|
|
|
// RETURNING id if id column exists
|
|
String insertedId = null;
|
|
if (colTypes.containsKey("id")) {
|
|
List<Map<String, Object>> result = jdbcTemplate.queryForList(sql + " RETURNING id", params);
|
|
if (!result.isEmpty()) insertedId = String.valueOf(result.get(0).get("id"));
|
|
} else {
|
|
jdbcTemplate.update(sql, params);
|
|
}
|
|
|
|
log.info("테이블 데이터 추가: {}, id={}", safeTable, insertedId);
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("inserted_id", insertedId);
|
|
result.put("saved_columns", existingCols);
|
|
return result;
|
|
}
|
|
|
|
@Transactional
|
|
public void editTableData(String tableName, Map<String, Object> originalData,
|
|
Map<String, Object> updatedData) {
|
|
String safeTable = sanitize(tableName);
|
|
Map<String, String> colTypes = getColumnTypes(safeTable);
|
|
|
|
// updated_date 자동 설정
|
|
if (colTypes.containsKey("updated_date") && !updatedData.containsKey("updated_date")) {
|
|
updatedData.put("updated_date", new java.sql.Timestamp(System.currentTimeMillis()));
|
|
}
|
|
|
|
// PK 컬럼 조회
|
|
List<String> pkCols = getPrimaryKeys(safeTable);
|
|
|
|
// SET 절
|
|
List<String> setClauses = new ArrayList<>();
|
|
List<Object> setValues = new ArrayList<>();
|
|
for (Map.Entry<String, Object> entry : updatedData.entrySet()) {
|
|
String col = sanitize(entry.getKey());
|
|
if (col.isBlank() || !colTypes.containsKey(entry.getKey())) continue;
|
|
setClauses.add(String.format("\"%s\" = ?", col));
|
|
setValues.add(entry.getValue());
|
|
}
|
|
|
|
if (setClauses.isEmpty()) return;
|
|
|
|
// WHERE 절
|
|
List<String> whereClauses = new ArrayList<>();
|
|
List<Object> whereValues = new ArrayList<>();
|
|
if (!pkCols.isEmpty()) {
|
|
for (String pk : pkCols) {
|
|
if (originalData.containsKey(pk)) {
|
|
whereClauses.add(String.format("\"%s\" = ?", sanitize(pk)));
|
|
whereValues.add(originalData.get(pk));
|
|
}
|
|
}
|
|
}
|
|
if (whereClauses.isEmpty()) {
|
|
for (Map.Entry<String, Object> entry : originalData.entrySet()) {
|
|
String col = sanitize(entry.getKey());
|
|
if (col.isBlank()) continue;
|
|
whereClauses.add(String.format("\"%s\" = ?", col));
|
|
whereValues.add(entry.getValue());
|
|
}
|
|
}
|
|
|
|
List<Object> allValues = new ArrayList<>(setValues);
|
|
allValues.addAll(whereValues);
|
|
|
|
String sql = String.format("UPDATE \"%s\" SET %s WHERE %s",
|
|
safeTable,
|
|
String.join(", ", setClauses),
|
|
String.join(" AND ", whereClauses));
|
|
|
|
jdbcTemplate.update(sql, allValues.toArray());
|
|
log.info("테이블 데이터 수정: {}", safeTable);
|
|
}
|
|
|
|
@Transactional
|
|
public int deleteTableData(String tableName, List<Map<String, Object>> dataList) {
|
|
String safeTable = sanitize(tableName);
|
|
Map<String, String> colTypes = getColumnTypes(safeTable);
|
|
List<String> pkCols = getPrimaryKeys(safeTable);
|
|
int deletedCount = 0;
|
|
|
|
for (Map<String, Object> row : dataList) {
|
|
List<String> whereClauses = new ArrayList<>();
|
|
List<Object> whereValues = new ArrayList<>();
|
|
|
|
if (!pkCols.isEmpty()) {
|
|
for (String pk : pkCols) {
|
|
if (row.containsKey(pk)) {
|
|
whereClauses.add(String.format("\"%s\" = ?", sanitize(pk)));
|
|
whereValues.add(row.get(pk));
|
|
}
|
|
}
|
|
}
|
|
if (whereClauses.isEmpty()) {
|
|
for (Map.Entry<String, Object> entry : row.entrySet()) {
|
|
String col = sanitize(entry.getKey());
|
|
if (col.isBlank() || !colTypes.containsKey(entry.getKey())) continue;
|
|
whereClauses.add(String.format("\"%s\" = ?", col));
|
|
whereValues.add(entry.getValue());
|
|
}
|
|
}
|
|
if (whereClauses.isEmpty()) continue;
|
|
|
|
String sql = String.format("DELETE FROM \"%s\" WHERE %s",
|
|
safeTable, String.join(" AND ", whereClauses));
|
|
deletedCount += jdbcTemplate.update(sql, whereValues.toArray());
|
|
}
|
|
log.info("테이블 데이터 삭제: {}, {}건", safeTable, deletedCount);
|
|
return deletedCount;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 로그 테이블 관리
|
|
// ──────────────────────────────────────────────────
|
|
|
|
@Transactional
|
|
public void createLogTable(String tableName, List<String> logColumns, boolean isActive) {
|
|
String safeOrig = sanitize(tableName);
|
|
if (safeOrig.isBlank()) {
|
|
throw new IllegalArgumentException("유효하지 않은 테이블명입니다.");
|
|
}
|
|
String safeLog = sanitize(safeOrig + "_log");
|
|
if (safeLog.isBlank()) {
|
|
throw new IllegalArgumentException("유효하지 않은 로그 테이블명입니다.");
|
|
}
|
|
|
|
// 원본 테이블 컬럼 정보 조회
|
|
Map<String, String> colTypes = getColumnTypes(safeOrig);
|
|
|
|
// 로그 테이블 생성 SQL 빌드
|
|
List<String> colDefs = new ArrayList<>();
|
|
colDefs.add("log_id BIGSERIAL PRIMARY KEY");
|
|
colDefs.add("log_action VARCHAR(10) NOT NULL");
|
|
colDefs.add("log_date TIMESTAMP DEFAULT NOW()");
|
|
colDefs.add("log_user VARCHAR(100)");
|
|
|
|
List<String> requestedCols = (logColumns != null && !logColumns.isEmpty())
|
|
? logColumns
|
|
: new ArrayList<>(colTypes.keySet());
|
|
|
|
// 실제 SQL 에 들어간 컬럼만 메타에 저장 (skip 된 것은 log_columns 설정에서도 빠짐)
|
|
List<String> persistedCols = new ArrayList<>();
|
|
for (String col : requestedCols) {
|
|
if (col == null) continue;
|
|
String safeCol = sanitize(col);
|
|
if (safeCol.isBlank()) continue; // sanitize 결과 빈 식별자 차단
|
|
if (!colTypes.containsKey(col)) continue; // 원본 테이블에 없는 컬럼 skip
|
|
|
|
String rawType = colTypes.get(col);
|
|
String normalized = (rawType == null ? "" : rawType.toLowerCase(Locale.ROOT).trim());
|
|
if (!ALLOWED_LOG_COLUMN_TYPES.contains(normalized)) {
|
|
// 알 수 없는 type 은 text 로 fallback (안전 default)
|
|
log.warn("로그 테이블 컬럼 타입 화이트리스트 미일치 → text 로 대체: table={}, col={}, type={}",
|
|
safeOrig, safeCol, rawType);
|
|
normalized = "text";
|
|
}
|
|
colDefs.add(String.format("\"%s\" %s", safeCol, normalized));
|
|
persistedCols.add(safeCol);
|
|
}
|
|
|
|
if (persistedCols.isEmpty()) {
|
|
throw new IllegalArgumentException("log 생성할 컬럼이 없습니다.");
|
|
}
|
|
|
|
String createSql = String.format(
|
|
"CREATE TABLE IF NOT EXISTS \"%s\" (%s)", safeLog, String.join(", ", colDefs));
|
|
jdbcTemplate.execute(createSql);
|
|
|
|
// log_tables 설정 저장
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("is_active", isActive);
|
|
params.put("log_columns", String.join(",", persistedCols));
|
|
sqlSession.update(NS + "upsertLogConfig", params);
|
|
|
|
log.info("로그 테이블 생성: {}", safeLog);
|
|
}
|
|
|
|
public Map<String, Object> getLogConfig(String tableName) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
return sqlSession.selectOne(NS + "getLogConfigInfo", params);
|
|
}
|
|
|
|
public Map<String, Object> getLogData(String tableName, int page, int size) {
|
|
String logTableName = sanitize(tableName) + "_log";
|
|
if (!checkTableExists(logTableName)) {
|
|
return Map.of("data", List.of(), "total", 0, "page", page, "size", size, "total_pages", 0);
|
|
}
|
|
int offset = (page - 1) * size;
|
|
String countSql = String.format("SELECT COUNT(*) FROM \"%s\"", logTableName);
|
|
Integer total = jdbcTemplate.queryForObject(countSql, Integer.class);
|
|
if (total == null) total = 0;
|
|
|
|
String dataSql = String.format(
|
|
"SELECT * FROM \"%s\" ORDER BY log_date DESC LIMIT %d OFFSET %d",
|
|
logTableName, size, offset);
|
|
List<Map<String, Object>> data = jdbcTemplate.queryForList(dataSql);
|
|
|
|
int totalPages = total == 0 ? 1 : (int) Math.ceil((double) total / size);
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
result.put("data", data);
|
|
result.put("total", total);
|
|
result.put("page", page);
|
|
result.put("size", size);
|
|
result.put("total_pages", totalPages);
|
|
return result;
|
|
}
|
|
|
|
@Transactional
|
|
public void toggleLogTable(String tableName, boolean isActive) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("is_active", isActive);
|
|
params.put("log_columns", "");
|
|
sqlSession.update(NS + "upsertLogConfig", params);
|
|
log.info("로그 테이블 토글: {} → {}", tableName, isActive);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// DB 연결 확인
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> checkDatabaseConnection() {
|
|
try {
|
|
sqlSession.selectOne(NS + "checkDatabaseConnection", null);
|
|
return Map.of("connected", true, "message", "데이터베이스 연결 정상");
|
|
} catch (Exception e) {
|
|
log.error("DB 연결 실패", e);
|
|
return Map.of("connected", false, "message", "데이터베이스 연결 실패: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 카테고리 / 채번 컬럼
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public List<Map<String, Object>> getCategoryColumnsByCompany(String companyCode) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("company_code", companyCode);
|
|
return sqlSession.selectList(NS + "getCategoryColumnListByCompany", params);
|
|
}
|
|
|
|
public List<Map<String, Object>> getNumberingColumnsByCompany(String companyCode) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("company_code", companyCode);
|
|
return sqlSession.selectList(NS + "getNumberingColumnListByCompany", params);
|
|
}
|
|
|
|
public List<Map<String, Object>> getCategoryColumnsByMenu(String companyCode, Object menuObjid) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("company_code", companyCode);
|
|
params.put("menu_objid", menuObjid);
|
|
return sqlSession.selectList(NS + "getCategoryColumnListByMenu", params);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 엔티티 관계
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> getTableEntityRelations(String leftTable, String rightTable,
|
|
String companyCode) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("left_table", leftTable);
|
|
params.put("right_table", rightTable);
|
|
params.put("company_code", companyCode);
|
|
List<Map<String, Object>> relations = sqlSession.selectList(NS + "getEntityRelationList", params);
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("left_table", leftTable);
|
|
result.put("right_table", rightTable);
|
|
result.put("relations", relations);
|
|
return result;
|
|
}
|
|
|
|
public List<Map<String, Object>> getReferencedByTables(String tableName, String companyCode) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
params.put("company_code", companyCode);
|
|
return sqlSession.selectList(NS + "getReferencedByTableList", params);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 다중 테이블 저장
|
|
// ──────────────────────────────────────────────────
|
|
|
|
@Transactional
|
|
public Map<String, Object> multiTableSave(Map<String, Object> payload, String companyCode) {
|
|
@SuppressWarnings("unchecked")
|
|
Map<String, Object> mainData = (Map<String, Object>) payload.get("main_data");
|
|
String mainTable = (String) payload.get("main_table");
|
|
@SuppressWarnings("unchecked")
|
|
List<Map<String, Object>> subTables = (List<Map<String, Object>>) payload.get("sub_tables");
|
|
|
|
if (mainTable == null || mainData == null) {
|
|
throw new IllegalArgumentException("mainTable과 mainData가 필요합니다.");
|
|
}
|
|
|
|
// 멀티테넌시
|
|
if (!mainData.containsKey("company_code") && hasColumn(sanitize(mainTable), "company_code")) {
|
|
mainData.put("company_code", companyCode);
|
|
}
|
|
|
|
Map<String, Object> mainResult = addTableData(mainTable, mainData);
|
|
String mainId = (String) mainResult.get("inserted_id");
|
|
|
|
List<Map<String, Object>> subResults = new ArrayList<>();
|
|
if (subTables != null) {
|
|
for (Map<String, Object> subTable : subTables) {
|
|
String subTableName = (String) subTable.get("table_name");
|
|
@SuppressWarnings("unchecked")
|
|
List<Map<String, Object>> rows = (List<Map<String, Object>>) subTable.get("rows");
|
|
String fkColumn = (String) subTable.get("fk_column");
|
|
|
|
if (subTableName == null || rows == null) continue;
|
|
for (Map<String, Object> row : rows) {
|
|
if (fkColumn != null && mainId != null) row.put(fkColumn, mainId);
|
|
if (!row.containsKey("company_code") && hasColumn(sanitize(subTableName), "company_code")) {
|
|
row.put("company_code", companyCode);
|
|
}
|
|
addTableData(subTableName, row);
|
|
}
|
|
Map<String, Object> subResult = new HashMap<>();
|
|
subResult.put("table_name", subTableName);
|
|
subResult.put("row_count", rows.size());
|
|
subResults.add(subResult);
|
|
}
|
|
}
|
|
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("main_id", mainId);
|
|
result.put("sub_results", subResults);
|
|
log.info("다중 테이블 저장 완료: mainTable={}, mainId={}", mainTable, mainId);
|
|
return result;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 엑셀 데이터 검증
|
|
// ──────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> validateExcelData(String tableName, List<Map<String, Object>> rows,
|
|
String companyCode) {
|
|
List<Map<String, Object>> errors = new ArrayList<>();
|
|
for (int i = 0; i < rows.size(); i++) {
|
|
Map<String, Object> row = rows.get(i);
|
|
List<String> notNullViolations = validateNotNullConstraints(tableName, row, companyCode);
|
|
if (!notNullViolations.isEmpty()) {
|
|
Map<String, Object> err = new HashMap<>();
|
|
err.put("row", i + 1);
|
|
err.put("type", "NOT_NULL");
|
|
err.put("columns", notNullViolations);
|
|
errors.add(err);
|
|
}
|
|
}
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("valid", errors.isEmpty());
|
|
result.put("errors", errors);
|
|
result.put("total_rows", rows.size());
|
|
return result;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 내부 유틸
|
|
// ──────────────────────────────────────────────────
|
|
|
|
/** 테이블 컬럼명 → DB 타입 맵 */
|
|
private Map<String, String> getColumnTypes(String safeTable) {
|
|
String sql = "SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ?";
|
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, safeTable);
|
|
Map<String, String> map = new LinkedHashMap<>();
|
|
for (Map<String, Object> row : rows) {
|
|
map.put((String) row.get("column_name"), (String) row.get("data_type"));
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/** 테이블 PK 컬럼 목록 */
|
|
private List<String> getPrimaryKeys(String safeTable) {
|
|
String sql = "SELECT kcu.column_name FROM information_schema.table_constraints tc " +
|
|
"JOIN information_schema.key_column_usage kcu " +
|
|
"ON tc.constraint_name = kcu.constraint_name " +
|
|
"WHERE tc.table_name = ? AND tc.constraint_type = 'PRIMARY KEY' " +
|
|
"ORDER BY kcu.ordinal_position";
|
|
return jdbcTemplate.queryForList(sql, String.class, safeTable);
|
|
}
|
|
|
|
/** SQL injection 방지용 식별자 정리 */
|
|
private String sanitize(String name) {
|
|
if (name == null) return "";
|
|
return name.replaceAll("[^a-zA-Z0-9_]", "");
|
|
}
|
|
|
|
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
|
|
private String normalizeInputType(String inputType) {
|
|
if ("direct".equals(inputType) || "auto".equals(inputType)) {
|
|
log.warn("잘못된 inputType 값 감지: {} → 'text'로 변환", inputType);
|
|
return "text";
|
|
}
|
|
return inputType != null ? inputType : "text";
|
|
}
|
|
|
|
/**
|
|
* context 에 따라 INPUT_TYPE 정규화 및 검증.
|
|
*/
|
|
private String normalizeInputType(String value, InputTypeContext context) {
|
|
if (context == InputTypeContext.USER_INSERT || context == InputTypeContext.USER_UPDATE_TYPE) {
|
|
if (value == null || !InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(value)) {
|
|
throw new IllegalArgumentException(
|
|
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
|
+ " (받은 값: " + value + ")"
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
// USER_UPDATE_OTHER / SYSTEM_NORMALIZE: 기존 동작 그대로
|
|
return normalizeInputType(value);
|
|
}
|
|
|
|
private String toJsonString(Object obj) {
|
|
if (obj == null) return "{}";
|
|
if (obj instanceof String s) return s.isBlank() ? "{}" : s;
|
|
try {
|
|
return objectMapper.writeValueAsString(obj);
|
|
} catch (Exception e) {
|
|
log.warn("JSON 직렬화 실패, 빈 객체로 처리: {}", e.getMessage());
|
|
return "{}";
|
|
}
|
|
}
|
|
|
|
private int toInt(Object val, int def) {
|
|
if (val == null) return def;
|
|
try { return ((Number) val).intValue(); } catch (Exception e) {
|
|
try { return Integer.parseInt(val.toString()); } catch (Exception ex) { return def; }
|
|
}
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private List<String> parseColumnArray(Object cols) {
|
|
if (cols instanceof List) return (List<String>) cols;
|
|
if (cols instanceof String s) {
|
|
return Arrays.stream(s.replace("{", "").replace("}", "").split(","))
|
|
.filter(c -> !c.isBlank())
|
|
.collect(Collectors.toList());
|
|
}
|
|
return List.of();
|
|
}
|
|
|
|
private void syncScreenLayouts(String tableName, String columnName,
|
|
String inputType, String companyCode) {
|
|
try {
|
|
Map<String, Object> p = new HashMap<>();
|
|
p.put("table_name", tableName);
|
|
p.put("column_name", columnName);
|
|
p.put("input_type", inputType);
|
|
p.put("company_code", companyCode);
|
|
p.put("component_id", mapInputTypeToComponentId(inputType));
|
|
sqlSession.update(NS + "syncScreenLayoutsInputType", p);
|
|
} catch (Exception e) {
|
|
log.warn("화면 레이아웃 동기화 실패 (무시됨): {}.{}", tableName, columnName);
|
|
}
|
|
}
|
|
|
|
private String mapInputTypeToComponentId(String inputType) {
|
|
if (inputType == null) return "text-input";
|
|
return switch (inputType) {
|
|
case "number", "decimal" -> "number-input";
|
|
case "date", "datetime", "time" -> "date-input";
|
|
case "textarea" -> "textarea-basic";
|
|
case "select", "dropdown", "code", "entity", "category" -> "select-basic";
|
|
case "checkbox" -> "checkbox-basic";
|
|
case "radio" -> "radio-basic";
|
|
case "boolean" -> "toggle-switch";
|
|
case "file" -> "file-upload";
|
|
case "image", "img", "picture", "photo" -> "image-widget";
|
|
case "button" -> "button-primary";
|
|
case "label" -> "text-display";
|
|
default -> "text-input";
|
|
};
|
|
}
|
|
}
|