From f530b3cf3164f903039b4bb1017378521eb1f313 Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 22 May 2026 14:43:15 +0900 Subject: [PATCH] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=EA=B4=80?= =?UTF-8?q?=EB=A6=AC):=20=EB=B8=94=EB=A1=9C=EC=BB=A4=205=EA=B1=B4=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=88=98=EC=A0=95=20(PR-A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 종합 감사 결과 발견된 블로커 5건: 1. AddColumnModal / CreateTableModal prop 미스매치 — 호출부 tableName/sourceTableName(camelCase) vs 인터페이스 table_name/source_table_name(snake_case). 결과적으로 컬럼 추가 기능이 실제로 동작하지 않았음 (/api/ddl/tables/undefined/columns). 호출부를 인터페이스에 맞춰 통일. 2. IS_NULLABLE 강제 'Y' 덮어쓰기 (backend mapper) — upsertColumnSettings 의 VALUES 절이 모든 케이스에서 'Y' 리터럴을 박음. NN 토글 후 "컬럼 설정 저장" 누르면 필수 입력 제약이 조용히 사라짐. COALESCE(#{is_nullable}, 'Y') 로 변경 + ON CONFLICT 절에 IS_NULLABLE COALESCE 추가. Service 도 settings 에서 is_nullable 을 읽어 'Y'/'N' 으로 정규화 후 전달. 3. 저장 안 한 편집 침묵 손실 — 우측 패널에서 편집 후 저장 안 누르고 다른 테이블로 이동 시 변경 사항 소실. hasUnsavedChanges memo 추가 (columns vs originalColumns JSON 비교). handleTableSelect 에서 dirty 면 confirm 다이얼로그. 4. PK / NN / IDX / UQ 위치별 비대칭 저장 — 그리드 행 칩은 즉시 저장하는데 우측 상세 패널 토글은 메모리만 변경하고 저장 버튼 필요. 두 위치 모두 즉시 저장으로 통일 (상세 패널의 onColumnChange 가 is_nullable / is_unique 필드를 받으면 handleNullableToggle / handleUniqueToggle 호출하도록). 5. SUPER_ADMIN role 검증 누락 (backend 보안) — DdlController 의 isSuperAdmin 이 company_code == "*" 만 보고 role 클레임 무시. 토큰 변조 또는 "*" 회사 소속만으로 운영 DB DROP / ALTER 가능. company_code "*" AND role "SUPER_ADMIN" 둘 다 충족 시에만 통과로 변경. 11개 엔드포인트 에 @RequestAttribute("role") 추가. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/erp/controller/DdlController.java | 45 ++++++++++++------- .../erp/service/TableManagementService.java | 15 +++++++ .../main/resources/mapper/tableManagement.xml | 3 +- .../admin/systemMng/tableMngList/page.tsx | 40 +++++++++++++++-- 4 files changed, 83 insertions(+), 20 deletions(-) diff --git a/backend-spring/src/main/java/com/erp/controller/DdlController.java b/backend-spring/src/main/java/com/erp/controller/DdlController.java index 3ad8a194..e627ca48 100644 --- a/backend-spring/src/main/java/com/erp/controller/DdlController.java +++ b/backend-spring/src/main/java/com/erp/controller/DdlController.java @@ -29,10 +29,11 @@ public class DdlController { @PostMapping("/tables") public ResponseEntity> createTable( @RequestAttribute("company_code") String companyCode, + @RequestAttribute(value = "role", required = false) String role, @RequestAttribute("user_id") String userId, @RequestBody Map body) { - if (!isSuperAdmin(companyCode)) { + if (!isSuperAdmin(companyCode, role)) { return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); } @@ -65,10 +66,11 @@ public class DdlController { public ResponseEntity> addColumn( @PathVariable String tableName, @RequestAttribute("company_code") String companyCode, + @RequestAttribute(value = "role", required = false) String role, @RequestAttribute("user_id") String userId, @RequestBody Map body) { - if (!isSuperAdmin(companyCode)) { + if (!isSuperAdmin(companyCode, role)) { return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); } @@ -99,9 +101,10 @@ public class DdlController { @PathVariable String tableName, @PathVariable String columnName, @RequestAttribute("company_code") String companyCode, + @RequestAttribute(value = "role", required = false) String role, @RequestAttribute("user_id") String userId) { - if (!isSuperAdmin(companyCode)) { + if (!isSuperAdmin(companyCode, role)) { return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); } @@ -124,9 +127,10 @@ public class DdlController { public ResponseEntity> dropTable( @PathVariable String tableName, @RequestAttribute("company_code") String companyCode, + @RequestAttribute(value = "role", required = false) String role, @RequestAttribute("user_id") String userId) { - if (!isSuperAdmin(companyCode)) { + if (!isSuperAdmin(companyCode, role)) { return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); } @@ -147,9 +151,10 @@ public class DdlController { @PostMapping("/validate/table") public ResponseEntity> validateTableCreation( @RequestAttribute("company_code") String companyCode, + @RequestAttribute(value = "role", required = false) String role, @RequestBody Map body) { - if (!isSuperAdmin(companyCode)) { + if (!isSuperAdmin(companyCode, role)) { return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); } @@ -176,11 +181,12 @@ public class DdlController { @GetMapping("/logs") public ResponseEntity> getDdlLogs( @RequestAttribute("company_code") String companyCode, + @RequestAttribute(value = "role", required = false) String role, @RequestParam(required = false, defaultValue = "50") int limit, @RequestParam(required = false) String userId, @RequestParam(required = false) String ddlType) { - if (!isSuperAdmin(companyCode)) { + if (!isSuperAdmin(companyCode, role)) { return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); } @@ -195,10 +201,11 @@ public class DdlController { @GetMapping("/statistics") public ResponseEntity> getDdlStatistics( @RequestAttribute("company_code") String companyCode, + @RequestAttribute(value = "role", required = false) String role, @RequestParam(required = false) String fromDate, @RequestParam(required = false) String toDate) { - if (!isSuperAdmin(companyCode)) { + if (!isSuperAdmin(companyCode, role)) { return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); } @@ -212,9 +219,10 @@ public class DdlController { @GetMapping("/tables/{tableName}/history") public ResponseEntity> getTableDdlHistory( @PathVariable String tableName, - @RequestAttribute("company_code") String companyCode) { + @RequestAttribute("company_code") String companyCode, + @RequestAttribute(value = "role", required = false) String role) { - if (!isSuperAdmin(companyCode)) { + if (!isSuperAdmin(companyCode, role)) { return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); } @@ -230,9 +238,10 @@ public class DdlController { @GetMapping("/tables/{tableName}/info") public ResponseEntity> getTableInfo( @PathVariable String tableName, - @RequestAttribute("company_code") String companyCode) { + @RequestAttribute("company_code") String companyCode, + @RequestAttribute(value = "role", required = false) String role) { - if (!isSuperAdmin(companyCode)) { + if (!isSuperAdmin(companyCode, role)) { return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); } @@ -255,9 +264,10 @@ public class DdlController { @DeleteMapping("/logs/cleanup") public ResponseEntity> cleanupOldLogs( @RequestAttribute("company_code") String companyCode, + @RequestAttribute(value = "role", required = false) String role, @RequestParam(required = false, defaultValue = "90") int retentionDays) { - if (!isSuperAdmin(companyCode)) { + if (!isSuperAdmin(companyCode, role)) { return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); } @@ -272,9 +282,10 @@ public class DdlController { */ @GetMapping("/info") public ResponseEntity> getInfo( - @RequestAttribute("company_code") String companyCode) { + @RequestAttribute("company_code") String companyCode, + @RequestAttribute(value = "role", required = false) String role) { - if (!isSuperAdmin(companyCode)) { + if (!isSuperAdmin(companyCode, role)) { return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); } @@ -318,7 +329,9 @@ public class DdlController { // 내부 유틸 // ───────────────────────────────────────────────────────────────────────── - private boolean isSuperAdmin(String companyCode) { - return "*".equals(companyCode); + private boolean isSuperAdmin(String companyCode, String role) { + // company_code 가 '*' 이고 role 이 SUPER_ADMIN 둘 다 충족해야 통과 (이중 체크). + // 토큰 변조 또는 회사코드만으로 super 권한이 발급되는 사고 방지. + return "*".equals(companyCode) && "SUPER_ADMIN".equals(role); } } diff --git a/backend-spring/src/main/java/com/erp/service/TableManagementService.java b/backend-spring/src/main/java/com/erp/service/TableManagementService.java index 22067b6e..99207fee 100644 --- a/backend-spring/src/main/java/com/erp/service/TableManagementService.java +++ b/backend-spring/src/main/java/com/erp/service/TableManagementService.java @@ -176,6 +176,21 @@ public class TableManagementService extends BaseService { 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)); + // is_nullable: 'Y'/'N' 또는 null. null 이면 mapper 의 COALESCE 로 기존 값 유지. + Object rawIsNullable = settings.get("is_nullable"); + if (rawIsNullable != null) { + String s = rawIsNullable.toString(); + // 프론트가 'YES'/'NO' 또는 'Y'/'N' 어느 쪽이든 보낼 수 있어 정규화 + if ("NO".equalsIgnoreCase(s) || "N".equalsIgnoreCase(s) || "FALSE".equalsIgnoreCase(s)) { + params.put("is_nullable", "N"); + } else if ("YES".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s) || "TRUE".equalsIgnoreCase(s)) { + params.put("is_nullable", "Y"); + } else { + params.put("is_nullable", null); + } + } else { + params.put("is_nullable", null); + } params.put("company_code", companyCode); params.put("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null); sqlSession.update(NS + "upsertColumnSettings", params); diff --git a/backend-spring/src/main/resources/mapper/tableManagement.xml b/backend-spring/src/main/resources/mapper/tableManagement.xml index 4e576fa3..55fc8b9c 100644 --- a/backend-spring/src/main/resources/mapper/tableManagement.xml +++ b/backend-spring/src/main/resources/mapper/tableManagement.xml @@ -300,7 +300,7 @@ , #{display_column} , #{display_order} , #{is_visible} - , 'Y' + , COALESCE(#{is_nullable}, 'Y') , #{company_code} , #{category_ref} , NOW() @@ -318,6 +318,7 @@ , DISPLAY_COLUMN = EXCLUDED.DISPLAY_COLUMN , DISPLAY_ORDER = COALESCE(EXCLUDED.DISPLAY_ORDER, TABLE_TYPE_COLUMNS.DISPLAY_ORDER) , IS_VISIBLE = COALESCE(EXCLUDED.IS_VISIBLE, TABLE_TYPE_COLUMNS.IS_VISIBLE) + , IS_NULLABLE = COALESCE(EXCLUDED.IS_NULLABLE, TABLE_TYPE_COLUMNS.IS_NULLABLE) , CATEGORY_REF = EXCLUDED.CATEGORY_REF , UPDATED_DATE = NOW() diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index c1612719..2d4e9b74 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -399,9 +399,28 @@ export default function TableManagementPage() { } }, []); + // 저장 안 한 변경 사항이 있는지 — columns 와 originalColumns 의 reference 비교 (immutable update 패턴 의존) + const hasUnsavedChanges = useMemo(() => { + if (columns.length === 0 || originalColumns.length === 0) return false; + if (columns.length !== originalColumns.length) return true; + // 직렬화 비교 (얕은 ref 만으론 부족 — handleColumnChange 가 새 객체를 만들지만 다른 필드는 같은 ref 일 수 있어서) + try { + return JSON.stringify(columns) !== JSON.stringify(originalColumns); + } catch { + return false; + } + }, [columns, originalColumns]); + // 테이블 선택 const handleTableSelect = useCallback( (tableName: string) => { + if (tableName === selectedTable) return; + if (hasUnsavedChanges) { + const ok = typeof window !== "undefined" + ? window.confirm("저장하지 않은 컬럼 변경 사항이 있습니다. 이동하면 변경 내용이 사라집니다. 계속할까요?") + : true; + if (!ok) return; + } setSelectedTable(tableName); setCurrentPage(1); setColumns([]); @@ -416,7 +435,7 @@ export default function TableManagementPage() { loadColumnTypes(tableName, 1, pageSize); loadConstraints(tableName); }, - [loadColumnTypes, loadConstraints, pageSize, tables], + [hasUnsavedChanges, loadColumnTypes, loadConstraints, pageSize, selectedTable, tables], ); // 입력 타입 변경 - 이전 타입의 설정값 초기화 포함 @@ -1808,6 +1827,21 @@ export default function TableManagementPage() { handleInputTypeChange(selectedColumn, value as string); return; } + // 그리드 칩과 동일하게 is_nullable/is_unique 는 즉시 저장 + if (field === "is_nullable") { + const currentColumn = columns.find((c) => c.column_name === selectedColumn); + if (currentColumn) { + handleNullableToggle(selectedColumn, currentColumn.is_nullable || "YES"); + } + return; + } + if (field === "is_unique") { + const currentColumn = columns.find((c) => c.column_name === selectedColumn); + if (currentColumn) { + handleUniqueToggle(selectedColumn, currentColumn.is_unique || "NO"); + } + return; + } if (field === "reference_table" && value) { loadReferenceTableColumns(value as string); } @@ -1857,13 +1891,13 @@ export default function TableManagementPage() { setDuplicateSourceTable(null); }} mode={duplicateModalMode} - sourceTableName={duplicateSourceTable || undefined} + source_table_name={duplicateSourceTable || undefined} /> setAddColumnModalOpen(false)} - tableName={selectedTable || ""} + table_name={selectedTable || ""} onSuccess={async (result) => { toast.success("컬럼이 성공적으로 추가되었습니다!"); // 테이블 목록 새로고침 (컬럼 수 업데이트)