Files
invyone/backend-spring/src/main/java/com/erp/service/TableManagementService.java
T
johngreen 1e1b3e103c fix(테이블타입): constraints SQL ARRAY_AGG → text 캐스트로 일원화
이전 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>
2026-05-14 09:18:26 +09:00

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";
};
}
}