Merge pull request #13 - fix+security: bug hunt 6 + 인가/SQL 2
Build & Deploy to K8s / build-and-deploy (push) Successful in 8m46s

johngreen -> main: 테이블 타입 관리 bug fix 6 + 보안 hardening 2
This commit was merged in pull request #13.
This commit is contained in:
2026-05-13 09:08:11 +00:00
7 changed files with 223 additions and 47 deletions
@@ -0,0 +1,13 @@
package com.erp.constants;
import java.util.Set;
public final class InputTypeConstants {
private InputTypeConstants() {}
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */
public static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
"text", "number", "date", "code", "entity",
"numbering", "file", "image"
);
}
@@ -0,0 +1,8 @@
package com.erp.constants;
public enum InputTypeContext {
USER_INSERT,
USER_UPDATE_TYPE,
USER_UPDATE_OTHER,
SYSTEM_NORMALIZE
}
@@ -75,7 +75,11 @@ public class TableManagementController {
@PutMapping("/tables/{tableName}/label") @PutMapping("/tables/{tableName}/label")
public ResponseEntity<ApiResponse<Void>> updateTableLabel( public ResponseEntity<ApiResponse<Void>> updateTableLabel(
@PathVariable String tableName, @PathVariable String tableName,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String displayName = (String) body.get("display_name"); String displayName = (String) body.get("display_name");
String description = (String) body.get("description"); String description = (String) body.get("description");
if (displayName == null || displayName.isBlank()) { if (displayName == null || displayName.isBlank()) {
@@ -105,7 +109,11 @@ public class TableManagementController {
@PathVariable String tableName, @PathVariable String tableName,
@PathVariable String columnName, @PathVariable String columnName,
@RequestBody Map<String, Object> settings, @RequestBody Map<String, Object> settings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) { @RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateColumnSettings(tableName, columnName, settings, companyCode); return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
} }
@@ -115,7 +123,11 @@ public class TableManagementController {
@PathVariable String tableName, @PathVariable String tableName,
@PathVariable String columnName, @PathVariable String columnName,
@RequestBody Map<String, Object> settings, @RequestBody Map<String, Object> settings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) { @RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateColumnSettings(tableName, columnName, settings, companyCode); return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
} }
@@ -136,7 +148,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsPost( public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsPost(
@PathVariable String tableName, @PathVariable String tableName,
@RequestBody List<Map<String, Object>> columnSettings, @RequestBody List<Map<String, Object>> columnSettings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) { @RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode); return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
} }
@@ -145,7 +161,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsBatch( public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsBatch(
@PathVariable String tableName, @PathVariable String tableName,
@RequestBody List<Map<String, Object>> columnSettings, @RequestBody List<Map<String, Object>> columnSettings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) { @RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode); return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
} }
@@ -166,7 +186,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateColumnWebType( public ResponseEntity<ApiResponse<Void>> updateColumnWebType(
@PathVariable String tableName, @PathVariable String tableName,
@PathVariable String columnName, @PathVariable String columnName,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String webType = (String) body.get("web_type"); String webType = (String) body.get("web_type");
if (webType == null || webType.isBlank()) { if (webType == null || webType.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("웹 타입이 필요합니다.")); return ResponseEntity.status(400).body(ApiResponse.error("웹 타입이 필요합니다."));
@@ -183,7 +207,11 @@ public class TableManagementController {
@PathVariable String tableName, @PathVariable String tableName,
@PathVariable String columnName, @PathVariable String columnName,
@RequestBody Map<String, Object> body, @RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) { @RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String inputType = (String) body.get("input_type"); String inputType = (String) body.get("input_type");
if (tableName == null || columnName == null || inputType == null || inputType.isBlank()) { if (tableName == null || columnName == null || inputType == null || inputType.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("테이블명, 컬럼명, 입력 타입이 모두 필요합니다.")); return ResponseEntity.status(400).body(ApiResponse.error("테이블명, 컬럼명, 입력 타입이 모두 필요합니다."));
@@ -241,7 +269,11 @@ public class TableManagementController {
@PutMapping("/tables/{tableName}/primary-key") @PutMapping("/tables/{tableName}/primary-key")
public ResponseEntity<ApiResponse<Void>> setTablePrimaryKey( public ResponseEntity<ApiResponse<Void>> setTablePrimaryKey(
@PathVariable String tableName, @PathVariable String tableName,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<String> columns = (List<String>) body.get("columns"); List<String> columns = (List<String>) body.get("columns");
if (tableName == null || columns == null || columns.isEmpty()) { if (tableName == null || columns == null || columns.isEmpty()) {
@@ -256,7 +288,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/indexes") @PostMapping("/tables/{tableName}/indexes")
public ResponseEntity<ApiResponse<Void>> toggleTableIndex( public ResponseEntity<ApiResponse<Void>> toggleTableIndex(
@PathVariable String tableName, @PathVariable String tableName,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
String columnName = (String) body.get("column_name"); String columnName = (String) body.get("column_name");
String indexType = (String) body.get("index_type"); String indexType = (String) body.get("index_type");
String action = (String) body.get("action"); String action = (String) body.get("action");
@@ -281,7 +317,11 @@ public class TableManagementController {
@PathVariable String tableName, @PathVariable String tableName,
@PathVariable String columnName, @PathVariable String columnName,
@RequestBody Map<String, Object> body, @RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) { @RequestAttribute("company_code") String companyCode) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
Object nullableObj = body.get("nullable"); Object nullableObj = body.get("nullable");
if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) { if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) {
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, nullable(boolean)이 필요합니다.")); return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, nullable(boolean)이 필요합니다."));
@@ -299,7 +339,11 @@ public class TableManagementController {
@PathVariable String tableName, @PathVariable String tableName,
@PathVariable String columnName, @PathVariable String columnName,
@RequestBody Map<String, Object> body, @RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) { @RequestAttribute("company_code") String companyCode) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
Object uniqueObj = body.get("unique"); Object uniqueObj = body.get("unique");
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) { if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, unique(boolean)이 필요합니다.")); return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, unique(boolean)이 필요합니다."));
@@ -366,7 +410,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Map<String, Object>>> addTableData( public ResponseEntity<ApiResponse<Map<String, Object>>> addTableData(
@PathVariable String tableName, @PathVariable String tableName,
@RequestBody Map<String, Object> data, @RequestBody Map<String, Object> data,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) { @RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (data == null || data.isEmpty()) { if (data == null || data.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("추가할 데이터가 필요합니다.")); return ResponseEntity.status(400).body(ApiResponse.error("추가할 데이터가 필요합니다."));
} }
@@ -399,7 +447,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> editTableData( public ResponseEntity<ApiResponse<Void>> editTableData(
@PathVariable String tableName, @PathVariable String tableName,
@RequestBody Map<String, Object> body, @RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) { @RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> originalData = (Map<String, Object>) body.get("original_data"); Map<String, Object> originalData = (Map<String, Object>) body.get("original_data");
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@@ -433,7 +485,11 @@ public class TableManagementController {
@DeleteMapping("/tables/{tableName}/delete") @DeleteMapping("/tables/{tableName}/delete")
public ResponseEntity<ApiResponse<Void>> deleteTableData( public ResponseEntity<ApiResponse<Void>> deleteTableData(
@PathVariable String tableName, @PathVariable String tableName,
@RequestBody Object body) { @RequestBody Object body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
List<Map<String, Object>> dataList; List<Map<String, Object>> dataList;
if (body instanceof List) { if (body instanceof List) {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@@ -457,7 +513,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/log") @PostMapping("/tables/{tableName}/log")
public ResponseEntity<ApiResponse<Void>> createLogTable( public ResponseEntity<ApiResponse<Void>> createLogTable(
@PathVariable String tableName, @PathVariable String tableName,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<String> logColumns = (List<String>) body.get("log_columns"); List<String> logColumns = (List<String>) body.get("log_columns");
boolean isActive = Boolean.TRUE.equals(body.get("is_active")); boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
@@ -487,7 +547,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/log/toggle") @PostMapping("/tables/{tableName}/log/toggle")
public ResponseEntity<ApiResponse<Void>> toggleLogTable( public ResponseEntity<ApiResponse<Void>> toggleLogTable(
@PathVariable String tableName, @PathVariable String tableName,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
boolean isActive = Boolean.TRUE.equals(body.get("is_active")); boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
tableManagementService.toggleLogTable(tableName, isActive); tableManagementService.toggleLogTable(tableName, isActive);
return ResponseEntity.ok(ApiResponse.success(null, return ResponseEntity.ok(ApiResponse.success(null,
@@ -544,7 +608,11 @@ public class TableManagementController {
@PostMapping("/multi-table-save") @PostMapping("/multi-table-save")
public ResponseEntity<ApiResponse<Map<String, Object>>> multiTableSave( public ResponseEntity<ApiResponse<Map<String, Object>>> multiTableSave(
@RequestBody Map<String, Object> payload, @RequestBody Map<String, Object> payload,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) { @RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return ResponseEntity.ok(ApiResponse.success( return ResponseEntity.ok(ApiResponse.success(
tableManagementService.multiTableSave(payload, companyCode), tableManagementService.multiTableSave(payload, companyCode),
"다중 테이블 저장이 완료되었습니다.")); "다중 테이블 저장이 완료되었습니다."));
@@ -575,4 +643,16 @@ public class TableManagementController {
return ResponseEntity.ok(ApiResponse.success( return ResponseEntity.ok(ApiResponse.success(
tableManagementService.checkDatabaseConnection(), "데이터베이스 연결 상태를 확인했습니다.")); tableManagementService.checkDatabaseConnection(), "데이터베이스 연결 상태를 확인했습니다."));
} }
// ──────────────────────────────────────────────────────────
// 권한 헬퍼
// ──────────────────────────────────────────────────────────
private boolean isAdmin(String role) {
return isSuperAdmin(role) || "COMPANY_ADMIN".equals(role);
}
private boolean isSuperAdmin(String roleOrCode) {
return "*".equals(roleOrCode) || "SUPER_ADMIN".equals(roleOrCode);
}
} }
@@ -1,6 +1,7 @@
package com.erp.service; package com.erp.service;
import com.erp.common.BaseService; import com.erp.common.BaseService;
import com.erp.constants.InputTypeConstants;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -39,12 +40,6 @@ public class DdlService extends BaseService {
"id", "created_date", "updated_date", "company_code" "id", "created_date", "updated_date", "company_code"
); );
/** 사용자가 신규 추가하는 컬럼에 허용되는 INPUT_TYPE 8종 (백엔드 백스톱) */
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
"text", "number", "date", "code", "entity",
"numbering", "file", "image"
);
public DdlService(JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) { public DdlService(JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.transactionTemplate = new TransactionTemplate(transactionManager); this.transactionTemplate = new TransactionTemplate(transactionManager);
@@ -146,9 +141,9 @@ public class DdlService extends BaseService {
transactionTemplate.execute(status -> { transactionTemplate.execute(status -> {
jdbcTemplate.execute(ddlQuery); jdbcTemplate.execute(ddlQuery);
String inputType = convertToInputType(column); String inputType = convertToInputType(column);
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) { if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES "INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + inputType + ")" + " (받은 값: " + inputType + ")"
); );
} }
@@ -421,9 +416,9 @@ public class DdlService extends BaseService {
for (int i = 0; i < columns.size(); i++) { for (int i = 0; i < columns.size(); i++) {
Map<String, Object> col = columns.get(i); Map<String, Object> col = columns.get(i);
String inputType = convertToInputType(col); String inputType = convertToInputType(col);
if (!USER_SELECTABLE_INPUT_TYPES.contains(inputType)) { if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES "INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + inputType + ")" + " (받은 값: " + inputType + ")"
); );
} }
@@ -532,6 +527,9 @@ public class DdlService extends BaseService {
case "radio" -> "radio"; case "radio" -> "radio";
case "code" -> "code"; case "code" -> "code";
case "entity" -> "entity"; case "entity" -> "entity";
case "file" -> "file";
case "image" -> "image";
case "numbering" -> "numbering";
default -> "text"; default -> "text";
}; };
} }
@@ -1,6 +1,8 @@
package com.erp.service; package com.erp.service;
import com.erp.common.BaseService; import com.erp.common.BaseService;
import com.erp.constants.InputTypeConstants;
import com.erp.constants.InputTypeContext;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -26,10 +28,14 @@ public class TableManagementService extends BaseService {
private static final String NS = "tableManagement."; private static final String NS = "tableManagement.";
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */ /** 로그 테이블 컬럼 정의에 허용하는 PostgreSQL data_type 화이트리스트.
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of( * information_schema.columns.data_type 값과 정확히 일치해야 한다. */
"text", "number", "date", "code", "entity", private static final Set<String> ALLOWED_LOG_COLUMN_TYPES = Set.of(
"numbering", "file", "image" "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"
); );
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
@@ -151,9 +157,12 @@ public class TableManagementService extends BaseService {
Map<String, Object> settings, String companyCode) { Map<String, Object> settings, String companyCode) {
ensureTableInLabels(tableName); ensureTableInLabels(tableName);
boolean inputTypeChanged = settings.containsKey("input_type"); Object rawInputType = settings.get("input_type");
String ctx = inputTypeChanged ? "user-update-type" : "user-update-other"; boolean inputTypeChanged = settings.containsKey("input_type") && rawInputType != null;
String inputType = normalizeInputType((String) settings.get("input_type"), ctx); InputTypeContext ctx = inputTypeChanged
? InputTypeContext.USER_UPDATE_TYPE
: InputTypeContext.USER_UPDATE_OTHER;
String inputType = normalizeInputType((String) rawInputType, ctx);
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName); params.put("table_name", tableName);
params.put("column_name", columnName); params.put("column_name", columnName);
@@ -210,7 +219,7 @@ public class TableManagementService extends BaseService {
public void updateColumnInputType(String tableName, String columnName, public void updateColumnInputType(String tableName, String columnName,
String inputType, String companyCode, String inputType, String companyCode,
Map<String, Object> detailSettings) { Map<String, Object> detailSettings) {
String finalType = normalizeInputType(inputType, "user-update-type"); String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE);
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName); params.put("table_name", tableName);
params.put("column_name", columnName); params.put("column_name", columnName);
@@ -611,9 +620,14 @@ public class TableManagementService extends BaseService {
@Transactional @Transactional
public void createLogTable(String tableName, List<String> logColumns, boolean isActive) { public void createLogTable(String tableName, List<String> logColumns, boolean isActive) {
String logTableName = tableName + "_log";
String safeLog = sanitize(logTableName);
String safeOrig = sanitize(tableName); 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); Map<String, String> colTypes = getColumnTypes(safeOrig);
@@ -625,13 +639,32 @@ public class TableManagementService extends BaseService {
colDefs.add("log_date TIMESTAMP DEFAULT NOW()"); colDefs.add("log_date TIMESTAMP DEFAULT NOW()");
colDefs.add("log_user VARCHAR(100)"); colDefs.add("log_user VARCHAR(100)");
List<String> targetCols = (logColumns != null && !logColumns.isEmpty()) List<String> requestedCols = (logColumns != null && !logColumns.isEmpty())
? logColumns.stream().map(this::sanitize).filter(c -> !c.isBlank()).collect(Collectors.toList()) ? logColumns
: new ArrayList<>(colTypes.keySet()); : new ArrayList<>(colTypes.keySet());
for (String col : targetCols) { // 실제 SQL 에 들어간 컬럼만 메타에 저장 (skip 된 것은 log_columns 설정에서도 빠짐)
String type = colTypes.getOrDefault(col, "TEXT"); List<String> persistedCols = new ArrayList<>();
colDefs.add(String.format("\"%s\" %s", col, type)); 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( String createSql = String.format(
@@ -642,7 +675,7 @@ public class TableManagementService extends BaseService {
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName); params.put("table_name", tableName);
params.put("is_active", isActive); params.put("is_active", isActive);
params.put("log_columns", String.join(",", targetCols)); params.put("log_columns", String.join(",", persistedCols));
sqlSession.update(NS + "upsertLogConfig", params); sqlSession.update(NS + "upsertLogConfig", params);
log.info("로그 테이블 생성: {}", safeLog); log.info("로그 테이블 생성: {}", safeLog);
@@ -872,19 +905,18 @@ public class TableManagementService extends BaseService {
/** /**
* context 에 따라 INPUT_TYPE 정규화 및 검증. * context 에 따라 INPUT_TYPE 정규화 및 검증.
* @param context "user-insert" | "user-update-type" | "user-update-other" | "system-normalize"
*/ */
private String normalizeInputType(String value, String context) { private String normalizeInputType(String value, InputTypeContext context) {
if ("user-insert".equals(context) || "user-update-type".equals(context)) { if (context == InputTypeContext.USER_INSERT || context == InputTypeContext.USER_UPDATE_TYPE) {
if (value == null || !USER_SELECTABLE_INPUT_TYPES.contains(value)) { if (value == null || !InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(value)) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES "INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + value + ")" + " (받은 값: " + value + ")"
); );
} }
return value; return value;
} }
// user-update-other / system-normalize: 기존 동작 그대로 // USER_UPDATE_OTHER / SYSTEM_NORMALIZE: 기존 동작 그대로
return normalizeInputType(value); return normalizeInputType(value);
} }
@@ -22,11 +22,24 @@ function countByInputType(columns: ColumnTypeInfo[]): Record<string, number> {
return counts; return counts;
} }
/** 도넛 차트용 비율 (0~1) 배열 및 라벨 순서 (8개 사용자 선택 가능 타입 한정) */ /** 도넛 차트용 segment (8개 base + legacy 그룹) */
function getDonutSegments(counts: Record<string, number>, total: number): Array<{ type: string; ratio: number }> { function getDonutSegments(
return (USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[]) counts: Record<string, number>,
total: number,
): Array<{ type: string; ratio: number; isLegacy: boolean }> {
const baseSegments = (USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[])
.filter((type) => (counts[type] || 0) > 0) .filter((type) => (counts[type] || 0) > 0)
.map((type) => ({ type, ratio: (counts[type] || 0) / total })); .map((type) => ({ type, ratio: (counts[type] || 0) / total, isLegacy: false }));
const legacyCount = Object.entries(counts)
.filter(([type]) => !(USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[]).includes(type))
.reduce((sum, [, count]) => sum + count, 0);
if (legacyCount > 0) {
baseSegments.push({ type: "__legacy__", ratio: legacyCount / total, isLegacy: true });
}
return baseSegments;
} }
export function TypeOverviewStrip({ export function TypeOverviewStrip({
@@ -44,16 +57,25 @@ export function TypeOverviewStrip({
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */ /** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
const circumference = 100; const circumference = 100;
let offset = 0; let offset = 0;
const segmentPaths = segments.map(({ type, ratio }) => { const LEGACY_CONF = {
color: "text-amber-600",
bgColor: "bg-amber-50",
barColor: "bg-amber-400",
label: "Legacy",
desc: "구버전 타입",
iconChar: "?",
};
const segmentPaths = segments.map(({ type, ratio, isLegacy }) => {
const length = ratio * circumference; const length = ratio * circumference;
const dashArray = `${length} ${circumference - length}`; const dashArray = `${length} ${circumference - length}`;
const dashOffset = -offset; const dashOffset = -offset;
offset += length; offset += length;
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" }; const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" });
return { return {
type, type,
dashArray, dashArray,
dashOffset, dashOffset,
isLegacy,
...conf, ...conf,
}; };
}); });
@@ -84,7 +106,7 @@ export function TypeOverviewStrip({
</svg> </svg>
</div> </div>
{/* 타입 칩 목록 (8개 사용자 선택 가능 타입 한정, 클릭 시 필터 토글) */} {/* 타입 칩 목록 (8개 base + legacy 그룹, 클릭 시 필터 토글) */}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{(USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[]) {(USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[])
.filter((type) => (counts[type] || 0) > 0) .filter((type) => (counts[type] || 0) > 0)
@@ -109,6 +131,29 @@ export function TypeOverviewStrip({
</button> </button>
); );
})} })}
{/* Legacy 칩 — 8개 외 구버전 타입 합산 */}
{(() => {
const legacyCount = Object.entries(counts)
.filter(([type]) => !(USER_SELECTABLE_INPUT_TYPE_ORDER as readonly string[]).includes(type))
.reduce((sum, [, count]) => sum + count, 0);
if (legacyCount === 0) return null;
const isActive = activeFilter === null || activeFilter === "__legacy__";
return (
<button
key="__legacy__"
type="button"
onClick={() => onFilterChange?.(activeFilter === "__legacy__" ? null : "__legacy__")}
className={cn(
"rounded-md border px-2 py-1 text-xs font-medium transition-colors",
"bg-amber-50 text-amber-600",
"border-amber-200",
isActive ? "ring-1 ring-ring" : "opacity-70 hover:opacity-100",
)}
>
Legacy {legacyCount}
</button>
);
})()}
</div> </div>
</div> </div>
); );
@@ -307,7 +307,7 @@ export function TableSettingModal({
const initialEdits: Record<string, Partial<ColumnTypeInfo>> = {}; const initialEdits: Record<string, Partial<ColumnTypeInfo>> = {};
columnsData.forEach((col) => { columnsData.forEach((col) => {
// reference_table이 설정되어 있으면 input_type은 entity여야 함 // reference_table이 설정되어 있으면 input_type은 entity여야 함
let effectiveInputType = col.input_type || "direct"; let effectiveInputType = col.input_type || "text";
if (col.reference_table && effectiveInputType !== "entity") { if (col.reference_table && effectiveInputType !== "entity") {
effectiveInputType = "entity"; effectiveInputType = "entity";
} }