2348800e68
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
카테고리/캐스케이딩 시스템 (B/C/D) 전부 폐기:
- BE: mapper/Service/Controller 9세트 삭제 (cascading*, categoryTree, tableCategoryValue, categoryValueCascading, codeMerge)
- FE: 페이지 3 + API 8 + hooks 2 + 폐기 컴포넌트 6 삭제, 14곳 의존성 정리
- DB: 12 테이블 DROP, TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename
신설 commonCode 마스터-디테일:
- code_info: 1레벨 그룹 마스터
- code_detail: 2~∞ depth 재귀 트리 (parent_detail_id self-FK, depth 자동 계산)
- API: /api/common-codes/{info,detail}
- CodeCategoryFormModal/Panel → CodeInfoFormModal/Panel rename
- code_category 컬럼명 전부 code_info 로 치환 (mapper/Java/FE)
- 옛 commonCode API URL (/categories/...) → getCodeOptions 어댑터 + /detail?code_info=... 전환
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1348 lines
63 KiB
Java
1348 lines
63 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_info", "code".equals(inputType) ? settings.get("code_info") : 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;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 동적 테이블 집계 (count / sum / avg / min / max / distinctCount)
|
|
// ──────────────────────────────────────────────────
|
|
|
|
private static final Set<String> AGG_TYPES = Set.of(
|
|
"count", "sum", "avg", "min", "max", "distinctCount"
|
|
);
|
|
|
|
private static final Set<String> FILTER_OPS = Set.of(
|
|
"=", "!=", ">", "<", ">=", "<=",
|
|
"like", "in", "notIn", "isNull", "isNotNull"
|
|
);
|
|
|
|
/**
|
|
* 단일 집계 값 계산.
|
|
*
|
|
* count — column 없이도 동작 (COUNT(*))
|
|
* sum/avg/min/max — column 필수
|
|
* distinctCount — column 필수 (COUNT(DISTINCT col))
|
|
*/
|
|
public Map<String, Object> aggregateTableData(String tableName, Map<String, Object> options) {
|
|
String safeTable = sanitize(tableName);
|
|
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
|
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
|
}
|
|
|
|
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
|
|
if (!AGG_TYPES.contains(aggregation)) {
|
|
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
|
|
}
|
|
|
|
String columnName = options.get("columnName") instanceof String s ? s : null;
|
|
String safeColumn = columnName != null ? sanitize(columnName) : "";
|
|
|
|
boolean columnRequired = !"count".equals(aggregation);
|
|
if (columnRequired) {
|
|
if (safeColumn.isBlank()) {
|
|
throw new IllegalArgumentException(aggregation + " 은 columnName 이 필요합니다.");
|
|
}
|
|
if (!hasColumn(safeTable, safeColumn)) {
|
|
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
|
|
}
|
|
} else if (!safeColumn.isBlank() && !hasColumn(safeTable, safeColumn)) {
|
|
// count + columnName 가 들어왔지만 실제 없는 컬럼이면 명확히 거절
|
|
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
|
|
}
|
|
|
|
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
|
|
|
List<Object> values = new ArrayList<>();
|
|
String where = buildAggregateWhere(safeTable, filters, values);
|
|
|
|
String selectExpr;
|
|
if ("count".equals(aggregation)) {
|
|
selectExpr = !safeColumn.isBlank()
|
|
? String.format("COUNT(\"%s\")", safeColumn)
|
|
: "COUNT(*)";
|
|
} else if ("distinctCount".equals(aggregation)) {
|
|
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeColumn);
|
|
} else {
|
|
// sum / avg / min / max — 숫자 캐스팅 (avg 만 numeric, 나머지는 컬럼 타입 그대로)
|
|
String upper = aggregation.toUpperCase();
|
|
if ("AVG".equals(upper) || "SUM".equals(upper)) {
|
|
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeColumn);
|
|
} else {
|
|
selectExpr = String.format("%s(\"%s\")", upper, safeColumn);
|
|
}
|
|
}
|
|
|
|
String sql = String.format("SELECT %s AS agg_value FROM \"%s\" main %s",
|
|
selectExpr, safeTable, where);
|
|
|
|
Number raw = jdbcTemplate.queryForObject(sql, Number.class, values.toArray());
|
|
double value = raw != null ? raw.doubleValue() : 0d;
|
|
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
result.put("value", value);
|
|
return result;
|
|
}
|
|
|
|
private String buildAggregateWhere(String safeTable, List<Map<String, Object>> filters, List<Object> values) {
|
|
if (filters == null || filters.isEmpty()) return "";
|
|
List<String> clauses = new ArrayList<>();
|
|
for (Map<String, Object> f : filters) {
|
|
if (f == null) continue;
|
|
String col = f.get("column") instanceof String s ? s : null;
|
|
String op = f.get("operator") instanceof String s ? s : "=";
|
|
if (col == null || col.isBlank()) continue;
|
|
String safeCol = sanitize(col);
|
|
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
|
|
if (!FILTER_OPS.contains(op)) continue;
|
|
|
|
Object val = f.get("value");
|
|
|
|
switch (op) {
|
|
case "isNull":
|
|
clauses.add(String.format("\"%s\" IS NULL", safeCol));
|
|
break;
|
|
case "isNotNull":
|
|
clauses.add(String.format("\"%s\" IS NOT NULL", safeCol));
|
|
break;
|
|
case "in":
|
|
case "notIn": {
|
|
List<Object> list = toList(val);
|
|
if (list.isEmpty()) continue;
|
|
String marks = list.stream().map(v -> "?").collect(Collectors.joining(", "));
|
|
String kw = "in".equals(op) ? "IN" : "NOT IN";
|
|
clauses.add(String.format("\"%s\" %s (%s)", safeCol, kw, marks));
|
|
values.addAll(list);
|
|
break;
|
|
}
|
|
case "like":
|
|
if (isEmptyAggregateFilterValue(val)) continue;
|
|
clauses.add(String.format("\"%s\"::text ILIKE ?", safeCol));
|
|
values.add("%" + val + "%");
|
|
break;
|
|
default:
|
|
if (isEmptyAggregateFilterValue(val)) continue;
|
|
clauses.add(String.format("\"%s\" %s ?", safeCol, op));
|
|
values.add(val);
|
|
}
|
|
}
|
|
return clauses.isEmpty() ? "" : "WHERE " + String.join(" AND ", clauses);
|
|
}
|
|
|
|
private List<Map<String, Object>> normalizeAggregateFilters(Object rawFilters) {
|
|
if (!(rawFilters instanceof List<?> rawList) || rawList.isEmpty()) {
|
|
return Collections.emptyList();
|
|
}
|
|
List<Map<String, Object>> out = new ArrayList<>();
|
|
for (Object item : rawList) {
|
|
if (item instanceof Map<?, ?> rawMap) {
|
|
Map<String, Object> normalized = new LinkedHashMap<>();
|
|
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
|
|
if (entry.getKey() instanceof String key) {
|
|
normalized.put(key, entry.getValue());
|
|
}
|
|
}
|
|
if (!normalized.isEmpty()) out.add(normalized);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
private boolean isEmptyAggregateFilterValue(Object val) {
|
|
if (val == null) return true;
|
|
if (val instanceof String s) return s.isBlank();
|
|
if (val instanceof Collection<?> c) return c.isEmpty();
|
|
return false;
|
|
}
|
|
|
|
private List<Object> toList(Object val) {
|
|
if (val == null) return List.of();
|
|
if (val instanceof List<?> l) {
|
|
List<Object> out = new ArrayList<>();
|
|
for (Object o : l) {
|
|
if (o == null) continue;
|
|
if (o instanceof String s && s.isBlank()) continue;
|
|
out.add(o);
|
|
}
|
|
return out;
|
|
}
|
|
if (val instanceof String s) {
|
|
if (s.isBlank()) return List.of();
|
|
return Arrays.stream(s.split(","))
|
|
.map(String::trim)
|
|
.filter(p -> !p.isEmpty())
|
|
.map(p -> (Object) p)
|
|
.collect(Collectors.toList());
|
|
}
|
|
return List.of(val);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 그룹별 집계 (Phase G.3 — canonical chart 용)
|
|
// ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* groupBy 컬럼별로 집계 결과 반환. canonical chart 컴포넌트가 bar / line / donut /
|
|
* horizontalBar 모두에서 같은 endpoint 를 사용.
|
|
*
|
|
* body 예:
|
|
* { "groupBy": "status", "aggregation": "count", "filters": [...] }
|
|
* { "groupBy": "dept_code", "aggregation": "sum", "valueColumn": "amount", "limit": 12 }
|
|
*
|
|
* response:
|
|
* { "rows": [{ "group": "재직", "value": 35 }, { "group": "휴직", "value": 4 }] }
|
|
*/
|
|
public Map<String, Object> aggregateTableGroup(String tableName, Map<String, Object> options) {
|
|
String safeTable = sanitize(tableName);
|
|
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
|
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
|
}
|
|
|
|
String groupBy = options.get("groupBy") instanceof String s ? s : null;
|
|
String safeGroupBy = groupBy != null ? sanitize(groupBy) : "";
|
|
if (safeGroupBy.isBlank() || !hasColumn(safeTable, safeGroupBy)) {
|
|
throw new IllegalArgumentException("groupBy 컬럼이 존재하지 않습니다: " + tableName + "." + groupBy);
|
|
}
|
|
|
|
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
|
|
if (!AGG_TYPES.contains(aggregation)) {
|
|
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
|
|
}
|
|
|
|
String valueColumn = options.get("valueColumn") instanceof String s ? s : null;
|
|
if (valueColumn == null && options.get("columnName") instanceof String s) valueColumn = s;
|
|
String safeValueCol = valueColumn != null ? sanitize(valueColumn) : "";
|
|
|
|
boolean columnRequired = !"count".equals(aggregation);
|
|
if (columnRequired) {
|
|
if (safeValueCol.isBlank()) {
|
|
throw new IllegalArgumentException(aggregation + " 은 valueColumn 이 필요합니다.");
|
|
}
|
|
if (!hasColumn(safeTable, safeValueCol)) {
|
|
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
|
|
}
|
|
} else if (!safeValueCol.isBlank() && !hasColumn(safeTable, safeValueCol)) {
|
|
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
|
|
}
|
|
|
|
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
|
|
|
int limit = toInt(options.get("limit"), 50);
|
|
if (limit < 1) limit = 50;
|
|
if (limit > 500) limit = 500;
|
|
|
|
String orderDir = options.get("orderDir") instanceof String s
|
|
&& ("asc".equalsIgnoreCase(s) || "desc".equalsIgnoreCase(s))
|
|
? s.toUpperCase()
|
|
: "DESC";
|
|
|
|
List<Object> values = new ArrayList<>();
|
|
String where = buildAggregateWhere(safeTable, filters, values);
|
|
|
|
String selectExpr;
|
|
if ("count".equals(aggregation)) {
|
|
selectExpr = !safeValueCol.isBlank()
|
|
? String.format("COUNT(\"%s\")", safeValueCol)
|
|
: "COUNT(*)";
|
|
} else if ("distinctCount".equals(aggregation)) {
|
|
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeValueCol);
|
|
} else {
|
|
String upper = aggregation.toUpperCase();
|
|
if ("AVG".equals(upper) || "SUM".equals(upper)) {
|
|
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeValueCol);
|
|
} else {
|
|
selectExpr = String.format("%s(\"%s\")", upper, safeValueCol);
|
|
}
|
|
}
|
|
|
|
String sql = String.format(
|
|
"SELECT \"%s\" AS group_value, %s AS agg_value " +
|
|
"FROM \"%s\" main %s " +
|
|
"GROUP BY \"%s\" " +
|
|
"ORDER BY agg_value %s NULLS LAST " +
|
|
"LIMIT %d",
|
|
safeGroupBy, selectExpr, safeTable, where, safeGroupBy, orderDir, limit);
|
|
|
|
List<Map<String, Object>> rawRows = jdbcTemplate.queryForList(sql, values.toArray());
|
|
List<Map<String, Object>> rows = new ArrayList<>();
|
|
for (Map<String, Object> r : rawRows) {
|
|
Object groupVal = r.get("group_value");
|
|
Object aggVal = r.get("agg_value");
|
|
double value = aggVal instanceof Number ? ((Number) aggVal).doubleValue() : 0d;
|
|
Map<String, Object> out = new LinkedHashMap<>();
|
|
out.put("group", groupVal);
|
|
out.put("value", value);
|
|
rows.add(out);
|
|
}
|
|
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
result.put("rows", rows);
|
|
return result;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 가벼운 select-rows (Phase G.3.1 — card-list / grouped-table 용)
|
|
// ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* OptionFilter 호환 필터 + orderBy + limit/offset 로 임의 컬럼들의 row 들을 반환.
|
|
* `getTableData` 는 페이지네이션 + ILIKE search 가 묶여 있어 view 컴포넌트가
|
|
* 사용하기 무겁다. 본 메서드는 raw rows 만 깔끔하게 반환.
|
|
*
|
|
* body 예:
|
|
* { "columns": ["user_name", "dept_code"], "filters": [...], "limit": 50 }
|
|
* { "groupBy 없이 단순 다중 컬럼", "orderBy": [{ "column": "created_date", "direction": "desc" }] }
|
|
*
|
|
* response:
|
|
* { "rows": [{...}, {...}] }
|
|
*/
|
|
public Map<String, Object> selectTableRows(String tableName, Map<String, Object> options) {
|
|
String safeTable = sanitize(tableName);
|
|
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
|
|
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
List<Object> rawColumns = options.get("columns") instanceof List<?> raw
|
|
? (List<Object>) raw : Collections.emptyList();
|
|
|
|
List<String> safeColumns = new ArrayList<>();
|
|
for (Object c : rawColumns) {
|
|
if (!(c instanceof String s)) continue;
|
|
String safe = sanitize(s);
|
|
if (safe.isBlank()) continue;
|
|
if (!hasColumn(safeTable, safe)) continue;
|
|
safeColumns.add(safe);
|
|
}
|
|
|
|
String selectExpr;
|
|
if (safeColumns.isEmpty()) {
|
|
selectExpr = "main.*";
|
|
} else {
|
|
selectExpr = safeColumns.stream()
|
|
.map(c -> "\"" + c + "\"")
|
|
.collect(Collectors.joining(", "));
|
|
}
|
|
|
|
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
|
|
|
|
List<Object> values = new ArrayList<>();
|
|
String where = buildAggregateWhere(safeTable, filters, values);
|
|
|
|
// orderBy: [{ column, direction }]
|
|
List<Map<String, Object>> orderBy = normalizeAggregateFilters(options.get("orderBy"));
|
|
|
|
List<String> orderClauses = new ArrayList<>();
|
|
for (Map<String, Object> ob : orderBy) {
|
|
if (ob == null) continue;
|
|
String col = ob.get("column") instanceof String s ? s : null;
|
|
if (col == null) continue;
|
|
String safeCol = sanitize(col);
|
|
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
|
|
String dir = ob.get("direction") instanceof String s
|
|
&& "desc".equalsIgnoreCase(s) ? "DESC" : "ASC";
|
|
orderClauses.add(String.format("\"%s\" %s", safeCol, dir));
|
|
}
|
|
String order = "";
|
|
if (!orderClauses.isEmpty()) {
|
|
order = "ORDER BY " + String.join(", ", orderClauses);
|
|
} else if (hasColumn(safeTable, "created_date")) {
|
|
order = "ORDER BY main.created_date DESC";
|
|
}
|
|
|
|
int limit = toInt(options.get("limit"), 50);
|
|
if (limit < 1) limit = 50;
|
|
if (limit > 500) limit = 500;
|
|
int offset = toInt(options.get("offset"), 0);
|
|
if (offset < 0) offset = 0;
|
|
|
|
String sql = String.format(
|
|
"SELECT %s FROM \"%s\" main %s %s LIMIT %d OFFSET %d",
|
|
selectExpr, safeTable, where, order, limit, offset);
|
|
|
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, values.toArray());
|
|
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
result.put("rows", rows);
|
|
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";
|
|
};
|
|
}
|
|
}
|