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 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> getTableList() { List> tables = sqlSession.selectList(NS + "getTableList"); // columnCount Long → Integer 변환 (Node 호환) for (Map t : tables) { Object cnt = t.get("column_count"); if (cnt instanceof Number) { t.put("column_count", ((Number) cnt).intValue()); } } return tables; } // ────────────────────────────────────────────────── // 컬럼 목록 // ────────────────────────────────────────────────── public Map getColumnList(String tableName, int page, int size, String companyCode) { Map 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> 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 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> getTableSchema(String tableName) { Map params = new HashMap<>(); params.put("table_name", tableName); return sqlSession.selectList(NS + "getTableSchemaList", params); } public boolean checkTableExists(String tableName) { Map params = new HashMap<>(); params.put("table_name", tableName); Map 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 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 getTableLabels(String tableName) { Map 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 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 params = new HashMap<>(); params.put("table_name", tableName); sqlSession.insert(NS + "insertTableLabelIfNotExists", params); } // ────────────────────────────────────────────────── // 컬럼 라벨 // ────────────────────────────────────────────────── public Map getColumnLabels(String tableName, String columnName) { Map 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 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 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> columnSettings, String companyCode) { ensureTableInLabels(tableName); for (Map 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 detailSettings) { String finalType = normalizeInputType(webType); Map 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 detailSettings) { String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE); Map 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> getColumnInputTypes(String tableName, String companyCode) { Map params = new HashMap<>(); params.put("table_name", tableName); params.put("company_code", companyCode); return sqlSession.selectList(NS + "getColumnInputTypeList", params); } // ────────────────────────────────────────────────── // 제약조건 관리 // ────────────────────────────────────────────────── public Map getTableConstraints(String tableName) { Map params = new HashMap<>(); params.put("table_name", tableName); List> pkResult = sqlSession.selectList(NS + "getTablePrimaryKeyList", params); Map primaryKey = new HashMap<>(); if (!pkResult.isEmpty()) { Map 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> indexResult = sqlSession.selectList(NS + "getTableIndexList", params); List> indexes = indexResult.stream().map(row -> { Map 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 result = new HashMap<>(); result.put("primary_key", primaryKey); result.put("indexes", indexes); return result; } @Transactional public void setTablePrimaryKey(String tableName, List columns) { String safeTable = sanitize(tableName); // 기존 PK 삭제 Map params = new HashMap<>(); params.put("table_name", safeTable); List> 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 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 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 validateNotNullConstraints(String tableName, Map data, String companyCode) { Map params = new HashMap<>(); params.put("table_name", tableName); params.put("company_code", companyCode); List> notNullCols = sqlSession.selectList(NS + "getNotNullColumnList", params); List violations = new ArrayList<>(); for (Map 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 validateUniqueConstraints(String tableName, Map data, String companyCode, String excludeId) { Map params = new HashMap<>(); params.put("table_name", tableName); params.put("company_code", companyCode); List> uniqueCols = sqlSession.selectList(NS + "getUniqueColumnList", params); String safeTable = sanitize(tableName); List violations = new ArrayList<>(); for (Map 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 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 getTableData(String tableName, Map 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 search = (Map) options.getOrDefault("search", Map.of()); String sortBy = (String) options.get("sort_by"); String sortOrder = "desc".equalsIgnoreCase((String) options.get("sort_order")) ? "DESC" : "ASC"; List conditions = new ArrayList<>(); List values = new ArrayList<>(); for (Map.Entry 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> data = jdbcTemplate.queryForList(dataSql, values.toArray()); int totalPages = total == 0 ? 1 : (int) Math.ceil((double) total / size); Map 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 addTableData(String tableName, Map data) { String safeTable = sanitize(tableName); // 테이블 컬럼 정보 조회 Map 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 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> 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 result = new HashMap<>(); result.put("inserted_id", insertedId); result.put("saved_columns", existingCols); return result; } @Transactional public void editTableData(String tableName, Map originalData, Map updatedData) { String safeTable = sanitize(tableName); Map 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 pkCols = getPrimaryKeys(safeTable); // SET 절 List setClauses = new ArrayList<>(); List setValues = new ArrayList<>(); for (Map.Entry 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 whereClauses = new ArrayList<>(); List 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 entry : originalData.entrySet()) { String col = sanitize(entry.getKey()); if (col.isBlank()) continue; whereClauses.add(String.format("\"%s\" = ?", col)); whereValues.add(entry.getValue()); } } List 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> dataList) { String safeTable = sanitize(tableName); Map colTypes = getColumnTypes(safeTable); List pkCols = getPrimaryKeys(safeTable); int deletedCount = 0; for (Map row : dataList) { List whereClauses = new ArrayList<>(); List 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 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 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 colTypes = getColumnTypes(safeOrig); // 로그 테이블 생성 SQL 빌드 List 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 requestedCols = (logColumns != null && !logColumns.isEmpty()) ? logColumns : new ArrayList<>(colTypes.keySet()); // 실제 SQL 에 들어간 컬럼만 메타에 저장 (skip 된 것은 log_columns 설정에서도 빠짐) List 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 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 getLogConfig(String tableName) { Map params = new HashMap<>(); params.put("table_name", tableName); return sqlSession.selectOne(NS + "getLogConfigInfo", params); } public Map 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> data = jdbcTemplate.queryForList(dataSql); int totalPages = total == 0 ? 1 : (int) Math.ceil((double) total / size); Map 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 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 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> getCategoryColumnsByCompany(String companyCode) { Map params = new HashMap<>(); params.put("company_code", companyCode); return sqlSession.selectList(NS + "getCategoryColumnListByCompany", params); } public List> getNumberingColumnsByCompany(String companyCode) { Map params = new HashMap<>(); params.put("company_code", companyCode); return sqlSession.selectList(NS + "getNumberingColumnListByCompany", params); } public List> getCategoryColumnsByMenu(String companyCode, Object menuObjid) { Map params = new HashMap<>(); params.put("company_code", companyCode); params.put("menu_objid", menuObjid); return sqlSession.selectList(NS + "getCategoryColumnListByMenu", params); } // ────────────────────────────────────────────────── // 엔티티 관계 // ────────────────────────────────────────────────── public Map getTableEntityRelations(String leftTable, String rightTable, String companyCode) { Map params = new HashMap<>(); params.put("left_table", leftTable); params.put("right_table", rightTable); params.put("company_code", companyCode); List> relations = sqlSession.selectList(NS + "getEntityRelationList", params); Map result = new HashMap<>(); result.put("left_table", leftTable); result.put("right_table", rightTable); result.put("relations", relations); return result; } public List> getReferencedByTables(String tableName, String companyCode) { Map params = new HashMap<>(); params.put("table_name", tableName); params.put("company_code", companyCode); return sqlSession.selectList(NS + "getReferencedByTableList", params); } // ────────────────────────────────────────────────── // 다중 테이블 저장 // ────────────────────────────────────────────────── @Transactional public Map multiTableSave(Map payload, String companyCode) { @SuppressWarnings("unchecked") Map mainData = (Map) payload.get("main_data"); String mainTable = (String) payload.get("main_table"); @SuppressWarnings("unchecked") List> subTables = (List>) 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 mainResult = addTableData(mainTable, mainData); String mainId = (String) mainResult.get("inserted_id"); List> subResults = new ArrayList<>(); if (subTables != null) { for (Map subTable : subTables) { String subTableName = (String) subTable.get("table_name"); @SuppressWarnings("unchecked") List> rows = (List>) subTable.get("rows"); String fkColumn = (String) subTable.get("fk_column"); if (subTableName == null || rows == null) continue; for (Map 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 subResult = new HashMap<>(); subResult.put("table_name", subTableName); subResult.put("row_count", rows.size()); subResults.add(subResult); } } Map result = new HashMap<>(); result.put("main_id", mainId); result.put("sub_results", subResults); log.info("다중 테이블 저장 완료: mainTable={}, mainId={}", mainTable, mainId); return result; } // ────────────────────────────────────────────────── // 엑셀 데이터 검증 // ────────────────────────────────────────────────── public Map validateExcelData(String tableName, List> rows, String companyCode) { List> errors = new ArrayList<>(); for (int i = 0; i < rows.size(); i++) { Map row = rows.get(i); List notNullViolations = validateNotNullConstraints(tableName, row, companyCode); if (!notNullViolations.isEmpty()) { Map err = new HashMap<>(); err.put("row", i + 1); err.put("type", "NOT_NULL"); err.put("columns", notNullViolations); errors.add(err); } } Map result = new HashMap<>(); result.put("valid", errors.isEmpty()); result.put("errors", errors); result.put("total_rows", rows.size()); return result; } // ────────────────────────────────────────────────── // 내부 유틸 // ────────────────────────────────────────────────── /** 테이블 컬럼명 → DB 타입 맵 */ private Map getColumnTypes(String safeTable) { String sql = "SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ?"; List> rows = jdbcTemplate.queryForList(sql, safeTable); Map map = new LinkedHashMap<>(); for (Map row : rows) { map.put((String) row.get("column_name"), (String) row.get("data_type")); } return map; } /** 테이블 PK 컬럼 목록 */ private List 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 parseColumnArray(Object cols) { if (cols instanceof List) return (List) 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 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"; }; } }