diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 8a01bdaf..ca92eea0 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -31,6 +31,7 @@ import layoutRoutes from "./routes/layoutRoutes"; import dataRoutes from "./routes/dataRoutes"; import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; +import ddlRoutes from "./routes/ddlRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -125,6 +126,7 @@ app.use("/api/screen", screenStandardRoutes); app.use("/api/data", dataRoutes); app.use("/api/test-button-dataflow", testButtonDataflowRoutes); app.use("/api/external-db-connections", externalDbConnectionRoutes); +app.use("/api/ddl", ddlRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/ddlController.ts b/backend-node/src/controllers/ddlController.ts new file mode 100644 index 00000000..006dceac --- /dev/null +++ b/backend-node/src/controllers/ddlController.ts @@ -0,0 +1,407 @@ +/** + * DDL 실행 컨트롤러 + * 테이블/컬럼 생성 API 엔드포인트 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/superAdminMiddleware"; +import { DDLExecutionService } from "../services/ddlExecutionService"; +import { DDLAuditLogger } from "../services/ddlAuditLogger"; +import { CreateTableRequest, AddColumnRequest } from "../types/ddl"; +import { logger } from "../utils/logger"; + +export class DDLController { + /** + * POST /api/ddl/tables - 새 테이블 생성 + */ + static async createTable( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName, columns, description }: CreateTableRequest = req.body; + const userId = req.user!.userId; + const userCompanyCode = req.user!.companyCode; + + // 입력값 기본 검증 + if (!tableName || !columns || columns.length === 0) { + res.status(400).json({ + success: false, + error: { + code: "INVALID_INPUT", + details: "테이블명과 최소 1개의 컬럼이 필요합니다.", + }, + }); + return; + } + + logger.info("테이블 생성 요청", { + tableName, + userId, + columnCount: columns.length, + ip: req.ip, + }); + + // DDL 실행 서비스 호출 + const ddlService = new DDLExecutionService(); + const result = await ddlService.createTable( + tableName, + columns, + userCompanyCode, + userId, + description + ); + + if (result.success) { + res.status(200).json({ + success: true, + message: result.message, + data: { + tableName, + columnCount: columns.length, + executedQuery: result.executedQuery, + }, + }); + } else { + res.status(400).json({ + success: false, + message: result.message, + error: result.error, + }); + } + } catch (error) { + logger.error("테이블 생성 컨트롤러 오류:", { + error: (error as Error).message, + stack: (error as Error).stack, + userId: req.user?.userId, + body: req.body, + }); + + res.status(500).json({ + success: false, + error: { + code: "INTERNAL_SERVER_ERROR", + details: "테이블 생성 중 서버 오류가 발생했습니다.", + }, + }); + } + } + + /** + * POST /api/ddl/tables/:tableName/columns - 컬럼 추가 + */ + static async addColumn( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName } = req.params; + const { column }: AddColumnRequest = req.body; + const userId = req.user!.userId; + const userCompanyCode = req.user!.companyCode; + + // 입력값 기본 검증 + if (!tableName) { + res.status(400).json({ + success: false, + error: { + code: "INVALID_INPUT", + details: "테이블명이 필요합니다.", + }, + }); + return; + } + + if (!column || !column.name || !column.webType) { + res.status(400).json({ + success: false, + error: { + code: "INVALID_INPUT", + details: "컬럼명과 웹타입이 필요합니다.", + }, + }); + return; + } + + logger.info("컬럼 추가 요청", { + tableName, + columnName: column.name, + webType: column.webType, + userId, + ip: req.ip, + }); + + // DDL 실행 서비스 호출 + const ddlService = new DDLExecutionService(); + const result = await ddlService.addColumn( + tableName, + column, + userCompanyCode, + userId + ); + + if (result.success) { + res.status(200).json({ + success: true, + message: result.message, + data: { + tableName, + columnName: column.name, + webType: column.webType, + executedQuery: result.executedQuery, + }, + }); + } else { + res.status(400).json({ + success: false, + message: result.message, + error: result.error, + }); + } + } catch (error) { + logger.error("컬럼 추가 컨트롤러 오류:", { + error: (error as Error).message, + stack: (error as Error).stack, + userId: req.user?.userId, + tableName: req.params.tableName, + body: req.body, + }); + + res.status(500).json({ + success: false, + error: { + code: "INTERNAL_SERVER_ERROR", + details: "컬럼 추가 중 서버 오류가 발생했습니다.", + }, + }); + } + } + + /** + * GET /api/ddl/logs - DDL 실행 로그 조회 + */ + static async getDDLLogs( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { limit, userId, ddlType } = req.query; + + const logs = await DDLAuditLogger.getRecentDDLLogs( + limit ? parseInt(limit as string) : 50, + userId as string, + ddlType as string + ); + + res.json({ + success: true, + data: { + logs, + total: logs.length, + }, + }); + } catch (error) { + logger.error("DDL 로그 조회 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "LOG_RETRIEVAL_FAILED", + details: "DDL 로그 조회 중 오류가 발생했습니다.", + }, + }); + } + } + + /** + * GET /api/ddl/statistics - DDL 실행 통계 조회 + */ + static async getDDLStatistics( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { fromDate, toDate } = req.query; + + const statistics = await DDLAuditLogger.getDDLStatistics( + fromDate ? new Date(fromDate as string) : undefined, + toDate ? new Date(toDate as string) : undefined + ); + + res.json({ + success: true, + data: statistics, + }); + } catch (error) { + logger.error("DDL 통계 조회 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "STATISTICS_RETRIEVAL_FAILED", + details: "DDL 통계 조회 중 오류가 발생했습니다.", + }, + }); + } + } + + /** + * GET /api/ddl/tables/:tableName/info - 생성된 테이블 정보 조회 + */ + static async getTableInfo( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName } = req.params; + + const ddlService = new DDLExecutionService(); + const tableInfo = await ddlService.getCreatedTableInfo(tableName); + + if (!tableInfo) { + res.status(404).json({ + success: false, + error: { + code: "TABLE_NOT_FOUND", + details: `테이블 '${tableName}'을 찾을 수 없습니다.`, + }, + }); + return; + } + + res.json({ + success: true, + data: tableInfo, + }); + } catch (error) { + logger.error("테이블 정보 조회 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "TABLE_INFO_RETRIEVAL_FAILED", + details: "테이블 정보 조회 중 오류가 발생했습니다.", + }, + }); + } + } + + /** + * GET /api/ddl/tables/:tableName/history - 테이블 DDL 히스토리 조회 + */ + static async getTableDDLHistory( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName } = req.params; + + const history = await DDLAuditLogger.getTableDDLHistory(tableName); + + res.json({ + success: true, + data: { + tableName, + history, + total: history.length, + }, + }); + } catch (error) { + logger.error("테이블 DDL 히스토리 조회 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "HISTORY_RETRIEVAL_FAILED", + details: "테이블 DDL 히스토리 조회 중 오류가 발생했습니다.", + }, + }); + } + } + + /** + * POST /api/ddl/validate/table - 테이블 생성 사전 검증 + */ + static async validateTableCreation( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName, columns }: CreateTableRequest = req.body; + + if (!tableName || !columns) { + res.status(400).json({ + success: false, + error: { + code: "INVALID_INPUT", + details: "테이블명과 컬럼 정보가 필요합니다.", + }, + }); + return; + } + + // 검증만 수행 (실제 생성하지 않음) + const { DDLSafetyValidator } = await import( + "../services/ddlSafetyValidator" + ); + const validationReport = DDLSafetyValidator.generateValidationReport( + tableName, + columns + ); + + res.json({ + success: true, + data: { + isValid: validationReport.validationResult.isValid, + errors: validationReport.validationResult.errors, + warnings: validationReport.validationResult.warnings, + summary: validationReport.summary, + }, + }); + } catch (error) { + logger.error("테이블 생성 검증 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "VALIDATION_ERROR", + details: "테이블 생성 검증 중 오류가 발생했습니다.", + }, + }); + } + } + + /** + * DELETE /api/ddl/logs/cleanup - 오래된 DDL 로그 정리 + */ + static async cleanupOldLogs( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { retentionDays } = req.query; + const days = retentionDays ? parseInt(retentionDays as string) : 90; + + const deletedCount = await DDLAuditLogger.cleanupOldLogs(days); + + res.json({ + success: true, + message: `${deletedCount}개의 오래된 DDL 로그가 삭제되었습니다.`, + data: { + deletedCount, + retentionDays: days, + }, + }); + } catch (error) { + logger.error("DDL 로그 정리 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "LOG_CLEANUP_FAILED", + details: "DDL 로그 정리 중 오류가 발생했습니다.", + }, + }); + } + } +} diff --git a/backend-node/src/middleware/superAdminMiddleware.ts b/backend-node/src/middleware/superAdminMiddleware.ts new file mode 100644 index 00000000..37b3f24a --- /dev/null +++ b/backend-node/src/middleware/superAdminMiddleware.ts @@ -0,0 +1,200 @@ +/** + * 슈퍼관리자 권한 검증 미들웨어 + * 회사코드가 '*'인 최고 관리자만 DDL 실행을 허용 + */ + +import { Request, Response, NextFunction } from "express"; +import { logger } from "../utils/logger"; + +// DDL 요청 시간 추적을 위한 메모리 저장소 +const ddlRequestTimes = new Map(); + +// AuthenticatedRequest 타입 확장 +export interface AuthenticatedRequest extends Request { + user?: { + userId: string; + userName: string; + companyCode: string; + userLang?: string; + }; +} + +/** + * 슈퍼관리자 권한 확인 미들웨어 + * 회사코드가 '*'이고 userId가 'plm_admin'인 사용자만 허용 + */ +export const requireSuperAdmin = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + // 인증 여부 확인 + if (!req.user) { + logger.warn("DDL 실행 시도 - 인증되지 않은 사용자", { + ip: req.ip, + userAgent: req.get("User-Agent"), + url: req.originalUrl, + }); + + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + // 슈퍼관리자 권한 확인 (회사코드가 '*'이고 plm_admin 사용자) + if (req.user.companyCode !== "*" || req.user.userId !== "plm_admin") { + logger.warn("DDL 실행 시도 - 권한 부족", { + userId: req.user.userId, + companyCode: req.user.companyCode, + ip: req.ip, + userAgent: req.get("User-Agent"), + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "SUPER_ADMIN_REQUIRED", + details: + "최고 관리자 권한이 필요합니다. DDL 실행은 회사코드가 '*'인 plm_admin 사용자만 가능합니다.", + }, + }); + return; + } + + // 권한 확인 로깅 + logger.info("DDL 실행 권한 확인 완료", { + userId: req.user.userId, + companyCode: req.user.companyCode, + ip: req.ip, + url: req.originalUrl, + }); + + next(); + } catch (error) { + logger.error("슈퍼관리자 권한 확인 중 오류 발생:", error); + + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * DDL 실행 전 추가 보안 검증 + * 세션 유효성 및 사용자 상태 재확인 + */ +export const validateDDLPermission = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + const user = req.user!; // requireSuperAdmin을 통과했으므로 user 존재 보장 + + // 세션 유효성 재확인 + if (!user.userId || !user.companyCode) { + logger.error("DDL 실행 - 세션 데이터 불완전", { + userId: user.userId, + companyCode: user.companyCode, + }); + + res.status(401).json({ + success: false, + error: { + code: "INVALID_SESSION", + details: "세션 정보가 불완전합니다. 다시 로그인해주세요.", + }, + }); + return; + } + + // 추가 보안 체크 - 메모리 기반 요청 시간 간격 제한 + const now = Date.now(); + const minInterval = 5000; // 5초 간격 제한 + const lastDDLTime = ddlRequestTimes.get(user.userId); + + if (lastDDLTime && now - lastDDLTime < minInterval) { + logger.warn("DDL 실행 - 너무 빈번한 요청", { + userId: user.userId, + timeSinceLastDDL: now - lastDDLTime, + }); + + res.status(429).json({ + success: false, + error: { + code: "TOO_MANY_REQUESTS", + details: + "DDL 실행 요청이 너무 빈번합니다. 잠시 후 다시 시도해주세요.", + }, + }); + return; + } + + // 마지막 DDL 실행 시간 기록 + ddlRequestTimes.set(user.userId, now); + + logger.info("DDL 실행 추가 보안 검증 완료", { + userId: user.userId, + companyCode: user.companyCode, + }); + + next(); + } catch (error) { + logger.error("DDL 권한 추가 검증 중 오류 발생:", error); + + res.status(500).json({ + success: false, + error: { + code: "VALIDATION_ERROR", + details: "권한 검증 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 사용자가 슈퍼관리자인지 확인하는 유틸리티 함수 + */ +export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => { + return user?.companyCode === "*" && user?.userId === "plm_admin"; +}; + +/** + * DDL 실행 권한 체크 (미들웨어 없이 사용) + */ +export const checkDDLPermission = ( + user: AuthenticatedRequest["user"] +): { + hasPermission: boolean; + errorCode?: string; + errorMessage?: string; +} => { + if (!user) { + return { + hasPermission: false, + errorCode: "AUTHENTICATION_REQUIRED", + errorMessage: "인증이 필요합니다.", + }; + } + + if (!isSuperAdmin(user)) { + return { + hasPermission: false, + errorCode: "SUPER_ADMIN_REQUIRED", + errorMessage: "최고 관리자 권한이 필요합니다.", + }; + } + + return { hasPermission: true }; +}; diff --git a/backend-node/src/routes/ddlRoutes.ts b/backend-node/src/routes/ddlRoutes.ts new file mode 100644 index 00000000..f32ae586 --- /dev/null +++ b/backend-node/src/routes/ddlRoutes.ts @@ -0,0 +1,215 @@ +/** + * DDL 실행 관련 라우터 + * 테이블/컬럼 생성 API 라우팅 + */ + +import express from "express"; +import { DDLController } from "../controllers/ddlController"; +import { + requireSuperAdmin, + validateDDLPermission, +} from "../middleware/superAdminMiddleware"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// ============================================ +// DDL 실행 라우터 (최고 관리자 전용) +// ============================================ + +/** + * 테이블 생성 + * POST /api/ddl/tables + */ +router.post( + "/tables", + authenticateToken, // 기본 인증 + requireSuperAdmin, // 슈퍼관리자 권한 확인 + validateDDLPermission, // DDL 실행 추가 검증 + DDLController.createTable +); + +/** + * 컬럼 추가 + * POST /api/ddl/tables/:tableName/columns + */ +router.post( + "/tables/:tableName/columns", + authenticateToken, + requireSuperAdmin, + validateDDLPermission, + DDLController.addColumn +); + +/** + * 테이블 생성 사전 검증 (실제 생성하지 않고 검증만) + * POST /api/ddl/validate/table + */ +router.post( + "/validate/table", + authenticateToken, + requireSuperAdmin, + DDLController.validateTableCreation +); + +// ============================================ +// DDL 로그 및 모니터링 라우터 +// ============================================ + +/** + * DDL 실행 로그 조회 + * GET /api/ddl/logs + */ +router.get( + "/logs", + authenticateToken, + requireSuperAdmin, + DDLController.getDDLLogs +); + +/** + * DDL 실행 통계 조회 + * GET /api/ddl/statistics + */ +router.get( + "/statistics", + authenticateToken, + requireSuperAdmin, + DDLController.getDDLStatistics +); + +/** + * 특정 테이블의 DDL 히스토리 조회 + * GET /api/ddl/tables/:tableName/history + */ +router.get( + "/tables/:tableName/history", + authenticateToken, + requireSuperAdmin, + DDLController.getTableDDLHistory +); + +/** + * 생성된 테이블 정보 조회 + * GET /api/ddl/tables/:tableName/info + */ +router.get( + "/tables/:tableName/info", + authenticateToken, + requireSuperAdmin, + DDLController.getTableInfo +); + +// ============================================ +// DDL 시스템 관리 라우터 +// ============================================ + +/** + * 오래된 DDL 로그 정리 + * DELETE /api/ddl/logs/cleanup + */ +router.delete( + "/logs/cleanup", + authenticateToken, + requireSuperAdmin, + DDLController.cleanupOldLogs +); + +// ============================================ +// 라우터 정보 및 헬스체크 +// ============================================ + +/** + * DDL 라우터 정보 조회 + * GET /api/ddl/info + */ +router.get("/info", authenticateToken, requireSuperAdmin, (req, res) => { + res.json({ + success: true, + data: { + service: "DDL Execution Service", + version: "1.0.0", + description: "PostgreSQL 테이블 및 컬럼 동적 생성 서비스", + endpoints: { + tables: { + create: "POST /api/ddl/tables", + addColumn: "POST /api/ddl/tables/:tableName/columns", + getInfo: "GET /api/ddl/tables/:tableName/info", + getHistory: "GET /api/ddl/tables/:tableName/history", + }, + validation: { + validateTable: "POST /api/ddl/validate/table", + }, + monitoring: { + logs: "GET /api/ddl/logs", + statistics: "GET /api/ddl/statistics", + cleanup: "DELETE /api/ddl/logs/cleanup", + }, + }, + requirements: { + authentication: "Bearer Token 필요", + authorization: "회사코드 '*'인 plm_admin 사용자만 접근 가능", + safety: "모든 DDL 실행은 안전성 검증 후 수행", + logging: "모든 DDL 실행은 감사 로그에 기록", + }, + supportedWebTypes: [ + "text", + "number", + "decimal", + "date", + "datetime", + "boolean", + "code", + "entity", + "textarea", + "select", + "checkbox", + "radio", + "file", + "email", + "tel", + ], + }, + }); +}); + +/** + * DDL 서비스 헬스체크 + * GET /api/ddl/health + */ +router.get("/health", authenticateToken, async (req, res) => { + try { + // 기본적인 데이터베이스 연결 테스트 + const { PrismaClient } = await import("@prisma/client"); + const prisma = new PrismaClient(); + + await prisma.$queryRaw`SELECT 1`; + await prisma.$disconnect(); + + res.json({ + success: true, + status: "healthy", + timestamp: new Date().toISOString(), + checks: { + database: "connected", + service: "operational", + }, + }); + } catch (error) { + res.status(503).json({ + success: false, + status: "unhealthy", + timestamp: new Date().toISOString(), + error: { + code: "HEALTH_CHECK_FAILED", + details: "DDL 서비스 상태 확인 실패", + }, + checks: { + database: "disconnected", + service: "error", + }, + }); + } +}); + +export default router; diff --git a/backend-node/src/services/ddlAuditLogger.ts b/backend-node/src/services/ddlAuditLogger.ts new file mode 100644 index 00000000..988e688f --- /dev/null +++ b/backend-node/src/services/ddlAuditLogger.ts @@ -0,0 +1,368 @@ +/** + * DDL 실행 감사 로깅 서비스 + * 모든 DDL 실행을 추적하고 기록 + */ + +import { PrismaClient } from "@prisma/client"; +import { logger } from "../utils/logger"; + +const prisma = new PrismaClient(); + +export class DDLAuditLogger { + /** + * DDL 실행 로그 기록 + */ + static async logDDLExecution( + userId: string, + companyCode: string, + ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN", + tableName: string, + ddlQuery: string, + success: boolean, + error?: string, + additionalInfo?: Record + ): Promise { + try { + // DDL 실행 로그 데이터베이스에 저장 + const logEntry = await prisma.$executeRaw` + INSERT INTO ddl_execution_log ( + user_id, + company_code, + ddl_type, + table_name, + ddl_query, + success, + error_message, + executed_at + ) VALUES ( + ${userId}, + ${companyCode}, + ${ddlType}, + ${tableName}, + ${ddlQuery}, + ${success}, + ${error || null}, + NOW() + ) + `; + + // 추가 로깅 (파일 로그) + const logData = { + userId, + companyCode, + ddlType, + tableName, + success, + queryLength: ddlQuery.length, + error: error || null, + additionalInfo: additionalInfo || null, + timestamp: new Date().toISOString(), + }; + + if (success) { + logger.info("DDL 실행 성공", logData); + } else { + logger.error("DDL 실행 실패", { ...logData, ddlQuery }); + } + + // 중요한 DDL 실행은 별도 알림 (필요시) + if (ddlType === "CREATE_TABLE" || ddlType === "DROP_TABLE") { + logger.warn("중요 DDL 실행", { + ...logData, + severity: "HIGH", + action: "TABLE_STRUCTURE_CHANGE", + }); + } + } catch (logError) { + // 로그 기록 실패는 시스템에 영향을 주지 않도록 처리 + logger.error("DDL 실행 로그 기록 실패:", { + originalUserId: userId, + originalDdlType: ddlType, + originalTableName: tableName, + originalSuccess: success, + logError: logError, + }); + + // 로그 기록 실패를 파일 로그로라도 남김 + console.error("CRITICAL: DDL 로그 기록 실패", { + userId, + ddlType, + tableName, + success, + logError, + timestamp: new Date().toISOString(), + }); + } + } + + /** + * DDL 실행 시작 로그 + */ + static async logDDLStart( + userId: string, + companyCode: string, + ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN", + tableName: string, + requestData: any + ): Promise { + logger.info("DDL 실행 시작", { + userId, + companyCode, + ddlType, + tableName, + requestData, + timestamp: new Date().toISOString(), + }); + } + + /** + * 최근 DDL 실행 로그 조회 + */ + static async getRecentDDLLogs( + limit: number = 50, + userId?: string, + ddlType?: string + ): Promise { + try { + let whereClause = "WHERE 1=1"; + const params: any[] = []; + + if (userId) { + whereClause += " AND user_id = $" + (params.length + 1); + params.push(userId); + } + + if (ddlType) { + whereClause += " AND ddl_type = $" + (params.length + 1); + params.push(ddlType); + } + + const query = ` + SELECT + id, + user_id, + company_code, + ddl_type, + table_name, + success, + error_message, + executed_at, + CASE + WHEN LENGTH(ddl_query) > 100 THEN SUBSTRING(ddl_query, 1, 100) || '...' + ELSE ddl_query + END as ddl_query_preview + FROM ddl_execution_log + ${whereClause} + ORDER BY executed_at DESC + LIMIT $${params.length + 1} + `; + + params.push(limit); + + const logs = await prisma.$queryRawUnsafe(query, ...params); + return logs as any[]; + } catch (error) { + logger.error("DDL 로그 조회 실패:", error); + return []; + } + } + + /** + * DDL 실행 통계 조회 + */ + static async getDDLStatistics( + fromDate?: Date, + toDate?: Date + ): Promise<{ + totalExecutions: number; + successfulExecutions: number; + failedExecutions: number; + byDDLType: Record; + byUser: Record; + recentFailures: any[]; + }> { + try { + let dateFilter = ""; + const params: any[] = []; + + if (fromDate) { + dateFilter += " AND executed_at >= $" + (params.length + 1); + params.push(fromDate); + } + + if (toDate) { + dateFilter += " AND executed_at <= $" + (params.length + 1); + params.push(toDate); + } + + // 전체 통계 + const totalStats = (await prisma.$queryRawUnsafe( + ` + SELECT + COUNT(*) as total_executions, + SUM(CASE WHEN success = true THEN 1 ELSE 0 END) as successful_executions, + SUM(CASE WHEN success = false THEN 1 ELSE 0 END) as failed_executions + FROM ddl_execution_log + WHERE 1=1 ${dateFilter} + `, + ...params + )) as any[]; + + // DDL 타입별 통계 + const ddlTypeStats = (await prisma.$queryRawUnsafe( + ` + SELECT ddl_type, COUNT(*) as count + FROM ddl_execution_log + WHERE 1=1 ${dateFilter} + GROUP BY ddl_type + ORDER BY count DESC + `, + ...params + )) as any[]; + + // 사용자별 통계 + const userStats = (await prisma.$queryRawUnsafe( + ` + SELECT user_id, COUNT(*) as count + FROM ddl_execution_log + WHERE 1=1 ${dateFilter} + GROUP BY user_id + ORDER BY count DESC + LIMIT 10 + `, + ...params + )) as any[]; + + // 최근 실패 로그 + const recentFailures = (await prisma.$queryRawUnsafe( + ` + SELECT + user_id, + ddl_type, + table_name, + error_message, + executed_at + FROM ddl_execution_log + WHERE success = false ${dateFilter} + ORDER BY executed_at DESC + LIMIT 10 + `, + ...params + )) as any[]; + + const stats = totalStats[0]; + + return { + totalExecutions: parseInt(stats.total_executions) || 0, + successfulExecutions: parseInt(stats.successful_executions) || 0, + failedExecutions: parseInt(stats.failed_executions) || 0, + byDDLType: ddlTypeStats.reduce((acc, row) => { + acc[row.ddl_type] = parseInt(row.count); + return acc; + }, {}), + byUser: userStats.reduce((acc, row) => { + acc[row.user_id] = parseInt(row.count); + return acc; + }, {}), + recentFailures, + }; + } catch (error) { + logger.error("DDL 통계 조회 실패:", error); + return { + totalExecutions: 0, + successfulExecutions: 0, + failedExecutions: 0, + byDDLType: {}, + byUser: {}, + recentFailures: [], + }; + } + } + + /** + * 특정 테이블의 DDL 히스토리 조회 + */ + static async getTableDDLHistory(tableName: string): Promise { + try { + const history = await prisma.$queryRawUnsafe( + ` + SELECT + id, + user_id, + ddl_type, + ddl_query, + success, + error_message, + executed_at + FROM ddl_execution_log + WHERE table_name = $1 + ORDER BY executed_at DESC + LIMIT 20 + `, + tableName + ); + + return history as any[]; + } catch (error) { + logger.error(`테이블 '${tableName}' DDL 히스토리 조회 실패:`, error); + return []; + } + } + + /** + * DDL 로그 정리 (오래된 로그 삭제) + */ + static async cleanupOldLogs(retentionDays: number = 90): Promise { + try { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + + const result = await prisma.$executeRaw` + DELETE FROM ddl_execution_log + WHERE executed_at < ${cutoffDate} + `; + + logger.info(`DDL 로그 정리 완료: ${result}개 레코드 삭제`, { + retentionDays, + cutoffDate: cutoffDate.toISOString(), + }); + + return result as number; + } catch (error) { + logger.error("DDL 로그 정리 실패:", error); + return 0; + } + } + + /** + * 긴급 상황 로그 (시스템 테이블 접근 시도 등) + */ + static async logSecurityAlert( + userId: string, + companyCode: string, + alertType: + | "SYSTEM_TABLE_ACCESS" + | "INVALID_PERMISSION" + | "SUSPICIOUS_ACTIVITY", + details: string, + requestData?: any + ): Promise { + try { + // 보안 알림은 별도의 고급 로깅 + logger.error("DDL 보안 알림", { + alertType, + userId, + companyCode, + details, + requestData, + severity: "CRITICAL", + timestamp: new Date().toISOString(), + }); + + // 필요시 외부 알림 시스템 연동 (이메일, 슬랙 등) + // await sendSecurityAlert(alertType, userId, details); + } catch (error) { + logger.error("보안 알림 기록 실패:", error); + } + } +} diff --git a/backend-node/src/services/ddlExecutionService.ts b/backend-node/src/services/ddlExecutionService.ts new file mode 100644 index 00000000..d327bd51 --- /dev/null +++ b/backend-node/src/services/ddlExecutionService.ts @@ -0,0 +1,625 @@ +/** + * DDL 실행 서비스 + * 실제 PostgreSQL 테이블 및 컬럼 생성을 담당 + */ + +import { PrismaClient } from "@prisma/client"; +import { + CreateColumnDefinition, + DDLExecutionResult, + WEB_TYPE_TO_POSTGRES_MAP, + WebType, +} from "../types/ddl"; +import { DDLSafetyValidator } from "./ddlSafetyValidator"; +import { DDLAuditLogger } from "./ddlAuditLogger"; +import { logger } from "../utils/logger"; +import { cache, CacheKeys } from "../utils/cache"; + +const prisma = new PrismaClient(); + +export class DDLExecutionService { + /** + * 새 테이블 생성 + */ + async createTable( + tableName: string, + columns: CreateColumnDefinition[], + userCompanyCode: string, + userId: string, + description?: string + ): Promise { + // DDL 실행 시작 로그 + await DDLAuditLogger.logDDLStart( + userId, + userCompanyCode, + "CREATE_TABLE", + tableName, + { columns, description } + ); + + try { + // 1. 권한 검증 + this.validateSuperAdminPermission(userCompanyCode); + + // 2. 안전성 검증 + const validation = DDLSafetyValidator.validateTableCreation( + tableName, + columns + ); + if (!validation.isValid) { + const errorMessage = `테이블 생성 검증 실패: ${validation.errors.join(", ")}`; + + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "CREATE_TABLE", + tableName, + "VALIDATION_FAILED", + false, + errorMessage + ); + + return { + success: false, + message: errorMessage, + error: { + code: "VALIDATION_FAILED", + details: validation.errors.join(", "), + }, + }; + } + + // 3. 테이블 존재 여부 확인 + const tableExists = await this.checkTableExists(tableName); + if (tableExists) { + const errorMessage = `테이블 '${tableName}'이 이미 존재합니다.`; + + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "CREATE_TABLE", + tableName, + "TABLE_EXISTS", + false, + errorMessage + ); + + return { + success: false, + message: errorMessage, + error: { + code: "TABLE_EXISTS", + details: errorMessage, + }, + }; + } + + // 4. DDL 쿼리 생성 + const ddlQuery = this.generateCreateTableQuery(tableName, columns); + + // 5. 트랜잭션으로 안전하게 실행 + await prisma.$transaction(async (tx) => { + // 5-1. 테이블 생성 + await tx.$executeRawUnsafe(ddlQuery); + + // 5-2. 테이블 메타데이터 저장 + await this.saveTableMetadata(tx, tableName, description); + + // 5-3. 컬럼 메타데이터 저장 + await this.saveColumnMetadata(tx, tableName, columns); + }); + + // 6. 성공 로그 기록 + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "CREATE_TABLE", + tableName, + ddlQuery, + true + ); + + logger.info("테이블 생성 성공", { + tableName, + userId, + columnCount: columns.length, + }); + + // 테이블 생성 후 관련 캐시 무효화 + this.invalidateTableCache(tableName); + + return { + success: true, + message: `테이블 '${tableName}'이 성공적으로 생성되었습니다.`, + executedQuery: ddlQuery, + }; + } catch (error) { + const errorMessage = `테이블 생성 실패: ${(error as Error).message}`; + + // 실패 로그 기록 + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "CREATE_TABLE", + tableName, + `FAILED: ${(error as Error).message}`, + false, + errorMessage + ); + + logger.error("테이블 생성 실패:", { + tableName, + userId, + error: (error as Error).message, + stack: (error as Error).stack, + }); + + return { + success: false, + message: errorMessage, + error: { + code: "EXECUTION_FAILED", + details: (error as Error).message, + }, + }; + } + } + + /** + * 기존 테이블에 컬럼 추가 + */ + async addColumn( + tableName: string, + column: CreateColumnDefinition, + userCompanyCode: string, + userId: string + ): Promise { + // DDL 실행 시작 로그 + await DDLAuditLogger.logDDLStart( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + { column } + ); + + try { + // 1. 권한 검증 + this.validateSuperAdminPermission(userCompanyCode); + + // 2. 안전성 검증 + const validation = DDLSafetyValidator.validateColumnAddition( + tableName, + column + ); + if (!validation.isValid) { + const errorMessage = `컬럼 추가 검증 실패: ${validation.errors.join(", ")}`; + + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + "VALIDATION_FAILED", + false, + errorMessage + ); + + return { + success: false, + message: errorMessage, + error: { + code: "VALIDATION_FAILED", + details: validation.errors.join(", "), + }, + }; + } + + // 3. 테이블 존재 여부 확인 + const tableExists = await this.checkTableExists(tableName); + if (!tableExists) { + const errorMessage = `테이블 '${tableName}'이 존재하지 않습니다.`; + + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + "TABLE_NOT_EXISTS", + false, + errorMessage + ); + + return { + success: false, + message: errorMessage, + error: { + code: "TABLE_NOT_EXISTS", + details: errorMessage, + }, + }; + } + + // 4. 컬럼 존재 여부 확인 + const columnExists = await this.checkColumnExists(tableName, column.name); + if (columnExists) { + const errorMessage = `컬럼 '${column.name}'이 이미 존재합니다.`; + + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + "COLUMN_EXISTS", + false, + errorMessage + ); + + return { + success: false, + message: errorMessage, + error: { + code: "COLUMN_EXISTS", + details: errorMessage, + }, + }; + } + + // 5. DDL 쿼리 생성 + const ddlQuery = this.generateAddColumnQuery(tableName, column); + + // 6. 트랜잭션으로 안전하게 실행 + await prisma.$transaction(async (tx) => { + // 6-1. 컬럼 추가 + await tx.$executeRawUnsafe(ddlQuery); + + // 6-2. 컬럼 메타데이터 저장 + await this.saveColumnMetadata(tx, tableName, [column]); + }); + + // 7. 성공 로그 기록 + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + ddlQuery, + true + ); + + logger.info("컬럼 추가 성공", { + tableName, + columnName: column.name, + webType: column.webType, + userId, + }); + + // 컬럼 추가 후 관련 캐시 무효화 + this.invalidateTableCache(tableName); + + return { + success: true, + message: `컬럼 '${column.name}'이 성공적으로 추가되었습니다.`, + executedQuery: ddlQuery, + }; + } catch (error) { + const errorMessage = `컬럼 추가 실패: ${(error as Error).message}`; + + // 실패 로그 기록 + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + `FAILED: ${(error as Error).message}`, + false, + errorMessage + ); + + logger.error("컬럼 추가 실패:", { + tableName, + columnName: column.name, + userId, + error: (error as Error).message, + stack: (error as Error).stack, + }); + + return { + success: false, + message: errorMessage, + error: { + code: "EXECUTION_FAILED", + details: (error as Error).message, + }, + }; + } + } + + /** + * CREATE TABLE DDL 쿼리 생성 + */ + private generateCreateTableQuery( + tableName: string, + columns: CreateColumnDefinition[] + ): string { + // 사용자 정의 컬럼들 + const columnDefinitions = columns + .map((col) => { + const postgresType = this.mapWebTypeToPostgresType( + col.webType, + col.length + ); + let definition = `"${col.name}" ${postgresType}`; + + if (!col.nullable) { + definition += " NOT NULL"; + } + + if (col.defaultValue) { + definition += ` DEFAULT '${col.defaultValue}'`; + } + + return definition; + }) + .join(",\n "); + + // 기본 컬럼들 (시스템 필수 컬럼) + const baseColumns = ` + "id" serial PRIMARY KEY, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(100), + "company_code" varchar(50) DEFAULT '*'`; + + // 최종 CREATE TABLE 쿼리 + return ` +CREATE TABLE "${tableName}" (${baseColumns}, + ${columnDefinitions} +);`.trim(); + } + + /** + * ALTER TABLE ADD COLUMN DDL 쿼리 생성 + */ + private generateAddColumnQuery( + tableName: string, + column: CreateColumnDefinition + ): string { + const postgresType = this.mapWebTypeToPostgresType( + column.webType, + column.length + ); + let definition = `"${column.name}" ${postgresType}`; + + if (!column.nullable) { + definition += " NOT NULL"; + } + + if (column.defaultValue) { + definition += ` DEFAULT '${column.defaultValue}'`; + } + + return `ALTER TABLE "${tableName}" ADD COLUMN ${definition};`; + } + + /** + * 웹타입을 PostgreSQL 타입으로 매핑 + */ + private mapWebTypeToPostgresType(webType: WebType, length?: number): string { + const mapping = WEB_TYPE_TO_POSTGRES_MAP[webType]; + + if (!mapping) { + logger.warn(`알 수 없는 웹타입: ${webType}, text로 대체`); + return "text"; + } + + if (mapping.supportsLength && length && length > 0) { + if (mapping.postgresType === "varchar") { + return `varchar(${length})`; + } + } + + return mapping.postgresType; + } + + /** + * 테이블 메타데이터 저장 + */ + private async saveTableMetadata( + tx: any, + tableName: string, + description?: string + ): Promise { + await tx.table_labels.upsert({ + where: { table_name: tableName }, + update: { + table_label: tableName, + description: description || `사용자 생성 테이블: ${tableName}`, + updated_date: new Date(), + }, + create: { + table_name: tableName, + table_label: tableName, + description: description || `사용자 생성 테이블: ${tableName}`, + created_date: new Date(), + updated_date: new Date(), + }, + }); + } + + /** + * 컬럼 메타데이터 저장 + */ + private async saveColumnMetadata( + tx: any, + tableName: string, + columns: CreateColumnDefinition[] + ): Promise { + // 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성 + await tx.table_labels.upsert({ + where: { + table_name: tableName, + }, + update: { + updated_date: new Date(), + }, + create: { + table_name: tableName, + table_label: tableName, + description: `자동 생성된 테이블 메타데이터: ${tableName}`, + created_date: new Date(), + updated_date: new Date(), + }, + }); + + for (const column of columns) { + await tx.column_labels.upsert({ + where: { + table_name_column_name: { + table_name: tableName, + column_name: column.name, + }, + }, + update: { + column_label: column.label || column.name, + web_type: column.webType, + detail_settings: JSON.stringify(column.detailSettings || {}), + description: column.description, + display_order: column.order || 0, + is_visible: true, + updated_date: new Date(), + }, + create: { + table_name: tableName, + column_name: column.name, + column_label: column.label || column.name, + web_type: column.webType, + detail_settings: JSON.stringify(column.detailSettings || {}), + description: column.description, + display_order: column.order || 0, + is_visible: true, + created_date: new Date(), + updated_date: new Date(), + }, + }); + } + } + + /** + * 권한 검증 (슈퍼관리자 확인) + */ + private validateSuperAdminPermission(userCompanyCode: string): void { + if (userCompanyCode !== "*") { + throw new Error("최고 관리자 권한이 필요합니다."); + } + } + + /** + * 테이블 존재 여부 확인 + */ + private async checkTableExists(tableName: string): Promise { + try { + const result = await prisma.$queryRawUnsafe( + ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ); + `, + tableName + ); + + return (result as any)[0]?.exists || false; + } catch (error) { + logger.error("테이블 존재 확인 오류:", error); + return false; + } + } + + /** + * 컬럼 존재 여부 확인 + */ + private async checkColumnExists( + tableName: string, + columnName: string + ): Promise { + try { + const result = await prisma.$queryRawUnsafe( + ` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + AND column_name = $2 + ); + `, + tableName, + columnName + ); + + return (result as any)[0]?.exists || false; + } catch (error) { + logger.error("컬럼 존재 확인 오류:", error); + return false; + } + } + + /** + * 생성된 테이블 정보 조회 + */ + async getCreatedTableInfo(tableName: string): Promise<{ + tableInfo: any; + columns: any[]; + } | null> { + try { + // 테이블 정보 조회 + const tableInfo = await prisma.table_labels.findUnique({ + where: { table_name: tableName }, + }); + + // 컬럼 정보 조회 + const columns = await prisma.column_labels.findMany({ + where: { table_name: tableName }, + orderBy: { display_order: "asc" }, + }); + + if (!tableInfo) { + return null; + } + + return { + tableInfo, + columns, + }; + } catch (error) { + logger.error("생성된 테이블 정보 조회 실패:", error); + return null; + } + } + + /** + * 테이블 관련 캐시 무효화 + * DDL 작업 후 호출하여 캐시된 데이터를 클리어 + */ + private invalidateTableCache(tableName: string): void { + try { + // 테이블 컬럼 관련 캐시 무효화 + const columnCacheDeleted = cache.deleteByPattern( + `table_columns:${tableName}` + ); + const countCacheDeleted = cache.deleteByPattern( + `table_column_count:${tableName}` + ); + cache.delete("table_list"); + + const totalDeleted = columnCacheDeleted + countCacheDeleted + 1; + + logger.info( + `테이블 캐시 무효화 완료: ${tableName}, 삭제된 키: ${totalDeleted}개` + ); + } catch (error) { + logger.warn(`테이블 캐시 무효화 실패: ${tableName}`, error); + } + } +} diff --git a/backend-node/src/services/ddlSafetyValidator.ts b/backend-node/src/services/ddlSafetyValidator.ts new file mode 100644 index 00000000..b7a44435 --- /dev/null +++ b/backend-node/src/services/ddlSafetyValidator.ts @@ -0,0 +1,390 @@ +/** + * DDL 안전성 검증 서비스 + * 테이블/컬럼 생성 전 모든 보안 검증을 수행 + */ + +import { + CreateColumnDefinition, + ValidationResult, + SYSTEM_TABLES, + RESERVED_WORDS, + RESERVED_COLUMNS, +} from "../types/ddl"; +import { logger } from "../utils/logger"; + +export class DDLSafetyValidator { + /** + * 테이블 생성 전 전체 검증 + */ + static validateTableCreation( + tableName: string, + columns: CreateColumnDefinition[] + ): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + try { + // 1. 테이블명 기본 검증 + const tableNameValidation = this.validateTableName(tableName); + if (!tableNameValidation.isValid) { + errors.push(...tableNameValidation.errors); + } + + // 2. 컬럼 기본 검증 + if (columns.length === 0) { + errors.push("최소 1개의 컬럼이 필요합니다."); + } + + // 3. 컬럼 목록 검증 + const columnsValidation = this.validateColumnList(columns); + if (!columnsValidation.isValid) { + errors.push(...columnsValidation.errors); + } + if (columnsValidation.warnings) { + warnings.push(...columnsValidation.warnings); + } + + // 4. 컬럼명 중복 검증 + const duplicateValidation = this.validateColumnDuplication(columns); + if (!duplicateValidation.isValid) { + errors.push(...duplicateValidation.errors); + } + + logger.info("테이블 생성 검증 완료", { + tableName, + columnCount: columns.length, + errorCount: errors.length, + warningCount: warnings.length, + }); + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } catch (error) { + logger.error("테이블 생성 검증 중 오류 발생:", error); + return { + isValid: false, + errors: ["테이블 생성 검증 중 내부 오류가 발생했습니다."], + }; + } + } + + /** + * 컬럼 추가 전 검증 + */ + static validateColumnAddition( + tableName: string, + column: CreateColumnDefinition + ): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + try { + // 1. 테이블명 검증 (시스템 테이블 확인) + if (this.isSystemTable(tableName)) { + errors.push( + `'${tableName}'은 시스템 테이블이므로 컬럼을 추가할 수 없습니다.` + ); + } + + // 2. 컬럼 정의 검증 + const columnValidation = this.validateSingleColumn(column); + if (!columnValidation.isValid) { + errors.push(...columnValidation.errors); + } + if (columnValidation.warnings) { + warnings.push(...columnValidation.warnings); + } + + logger.info("컬럼 추가 검증 완료", { + tableName, + columnName: column.name, + webType: column.webType, + errorCount: errors.length, + }); + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } catch (error) { + logger.error("컬럼 추가 검증 중 오류 발생:", error); + return { + isValid: false, + errors: ["컬럼 추가 검증 중 내부 오류가 발생했습니다."], + }; + } + } + + /** + * 테이블명 검증 + */ + private static validateTableName(tableName: string): ValidationResult { + const errors: string[] = []; + + // 1. 기본 형식 검증 + if (!this.isValidTableName(tableName)) { + errors.push( + "유효하지 않은 테이블명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다." + ); + } + + // 2. 길이 검증 + if (tableName.length > 63) { + errors.push("테이블명은 63자를 초과할 수 없습니다."); + } + + if (tableName.length < 2) { + errors.push("테이블명은 최소 2자 이상이어야 합니다."); + } + + // 3. 시스템 테이블 보호 + if (this.isSystemTable(tableName)) { + errors.push( + `'${tableName}'은 시스템 테이블명으로 사용할 수 없습니다. 다른 이름을 선택해주세요.` + ); + } + + // 4. 예약어 검증 + if (this.isReservedWord(tableName)) { + errors.push( + `'${tableName}'은 SQL 예약어이므로 테이블명으로 사용할 수 없습니다.` + ); + } + + // 5. 일반적인 네이밍 컨벤션 검증 + if (tableName.startsWith("_") || tableName.endsWith("_")) { + errors.push("테이블명은 언더스코어로 시작하거나 끝날 수 없습니다."); + } + + if (tableName.includes("__")) { + errors.push("테이블명에 연속된 언더스코어는 사용할 수 없습니다."); + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * 컬럼 목록 검증 + */ + private static validateColumnList( + columns: CreateColumnDefinition[] + ): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + for (let i = 0; i < columns.length; i++) { + const column = columns[i]; + const columnValidation = this.validateSingleColumn(column, i + 1); + + if (!columnValidation.isValid) { + errors.push(...columnValidation.errors); + } + + if (columnValidation.warnings) { + warnings.push(...columnValidation.warnings); + } + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } + + /** + * 개별 컬럼 검증 + */ + private static validateSingleColumn( + column: CreateColumnDefinition, + position?: number + ): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + const prefix = position + ? `컬럼 ${position}(${column.name}): ` + : `컬럼 '${column.name}': `; + + // 1. 컬럼명 기본 검증 + if (!column.name || column.name.trim() === "") { + errors.push(`${prefix}컬럼명은 필수입니다.`); + return { isValid: false, errors }; + } + + if (!this.isValidColumnName(column.name)) { + errors.push( + `${prefix}유효하지 않은 컬럼명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.` + ); + } + + // 2. 길이 검증 + if (column.name.length > 63) { + errors.push(`${prefix}컬럼명은 63자를 초과할 수 없습니다.`); + } + + if (column.name.length < 2) { + errors.push(`${prefix}컬럼명은 최소 2자 이상이어야 합니다.`); + } + + // 3. 예약된 컬럼명 검증 + if (this.isReservedColumnName(column.name)) { + errors.push( + `${prefix}'${column.name}'은 예약된 컬럼명입니다. 기본 컬럼(id, created_date, updated_date, company_code)과 중복됩니다.` + ); + } + + // 4. SQL 예약어 검증 + if (this.isReservedWord(column.name)) { + errors.push( + `${prefix}'${column.name}'은 SQL 예약어이므로 컬럼명으로 사용할 수 없습니다.` + ); + } + + // 5. 웹타입 검증 + if (!column.webType) { + errors.push(`${prefix}웹타입이 지정되지 않았습니다.`); + } + + // 6. 길이 설정 검증 (text, code 타입에서만 허용) + if (column.length !== undefined) { + if ( + !["text", "code", "email", "tel", "select", "radio"].includes( + column.webType + ) + ) { + warnings.push( + `${prefix}${column.webType} 타입에서는 길이 설정이 무시됩니다.` + ); + } else if (column.length <= 0 || column.length > 65535) { + errors.push(`${prefix}길이는 1 이상 65535 이하여야 합니다.`); + } + } + + // 7. 네이밍 컨벤션 검증 + if (column.name.startsWith("_") || column.name.endsWith("_")) { + warnings.push( + `${prefix}컬럼명이 언더스코어로 시작하거나 끝나는 것은 권장하지 않습니다.` + ); + } + + if (column.name.includes("__")) { + errors.push(`${prefix}컬럼명에 연속된 언더스코어는 사용할 수 없습니다.`); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } + + /** + * 컬럼명 중복 검증 + */ + private static validateColumnDuplication( + columns: CreateColumnDefinition[] + ): ValidationResult { + const errors: string[] = []; + const columnNames = columns.map((col) => col.name.toLowerCase()); + const seen = new Set(); + const duplicates = new Set(); + + for (const name of columnNames) { + if (seen.has(name)) { + duplicates.add(name); + } else { + seen.add(name); + } + } + + if (duplicates.size > 0) { + errors.push( + `중복된 컬럼명이 있습니다: ${Array.from(duplicates).join(", ")}` + ); + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * 테이블명 유효성 검증 (정규식) + */ + private static isValidTableName(tableName: string): boolean { + const tableNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + return tableNameRegex.test(tableName); + } + + /** + * 컬럼명 유효성 검증 (정규식) + */ + private static isValidColumnName(columnName: string): boolean { + const columnNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + return columnNameRegex.test(columnName) && columnName.length <= 63; + } + + /** + * 시스템 테이블 확인 + */ + private static isSystemTable(tableName: string): boolean { + return SYSTEM_TABLES.includes(tableName.toLowerCase() as any); + } + + /** + * SQL 예약어 확인 + */ + private static isReservedWord(word: string): boolean { + return RESERVED_WORDS.includes(word.toLowerCase() as any); + } + + /** + * 예약된 컬럼명 확인 + */ + private static isReservedColumnName(columnName: string): boolean { + return RESERVED_COLUMNS.includes(columnName.toLowerCase() as any); + } + + /** + * 전체 검증 통계 생성 + */ + static generateValidationReport( + tableName: string, + columns: CreateColumnDefinition[] + ): { + tableName: string; + totalColumns: number; + validationResult: ValidationResult; + summary: string; + } { + const validationResult = this.validateTableCreation(tableName, columns); + + let summary = `테이블 '${tableName}' 검증 완료. `; + summary += `컬럼 ${columns.length}개 중 `; + + if (validationResult.isValid) { + summary += "모든 검증 통과."; + } else { + summary += `${validationResult.errors.length}개 오류 발견.`; + } + + if (validationResult.warnings && validationResult.warnings.length > 0) { + summary += ` ${validationResult.warnings.length}개 경고 있음.`; + } + + return { + tableName, + totalColumns: columns.length, + validationResult, + summary, + }; + } +} diff --git a/backend-node/src/types/ddl.ts b/backend-node/src/types/ddl.ts new file mode 100644 index 00000000..ec84a983 --- /dev/null +++ b/backend-node/src/types/ddl.ts @@ -0,0 +1,314 @@ +/** + * DDL 실행 관련 타입 정의 + */ + +// 기본 웹타입 +export type WebType = + | "text" + | "number" + | "decimal" + | "date" + | "datetime" + | "boolean" + | "code" + | "entity" + | "textarea" + | "select" + | "checkbox" + | "radio" + | "file" + | "email" + | "tel"; + +// 컬럼 정의 인터페이스 +export interface CreateColumnDefinition { + /** 컬럼명 (영문자, 숫자, 언더스코어만 허용) */ + name: string; + /** 컬럼 라벨 (화면 표시용) */ + label?: string; + /** 웹타입 */ + webType: WebType; + /** NULL 허용 여부 */ + nullable?: boolean; + /** 컬럼 길이 (text, code 타입에서 사용) */ + length?: number; + /** 기본값 */ + defaultValue?: string; + /** 컬럼 설명 */ + description?: string; + /** 표시 순서 */ + order?: number; + /** 상세 설정 (JSON 형태) */ + detailSettings?: Record; +} + +// 테이블 생성 요청 인터페이스 +export interface CreateTableRequest { + /** 테이블명 */ + tableName: string; + /** 테이블 설명 */ + description?: string; + /** 컬럼 정의 목록 */ + columns: CreateColumnDefinition[]; +} + +// 컬럼 추가 요청 인터페이스 +export interface AddColumnRequest { + /** 컬럼 정의 */ + column: CreateColumnDefinition; +} + +// DDL 실행 결과 인터페이스 +export interface DDLExecutionResult { + /** 실행 성공 여부 */ + success: boolean; + /** 결과 메시지 */ + message: string; + /** 실행된 DDL 쿼리 */ + executedQuery?: string; + /** 오류 정보 */ + error?: { + code: string; + details: string; + }; +} + +// 검증 결과 인터페이스 +export interface ValidationResult { + /** 검증 통과 여부 */ + isValid: boolean; + /** 오류 메시지 목록 */ + errors: string[]; + /** 경고 메시지 목록 */ + warnings?: string[]; +} + +// DDL 실행 로그 인터페이스 +export interface DDLExecutionLog { + /** 로그 ID */ + id: number; + /** 사용자 ID */ + user_id: string; + /** 회사 코드 */ + company_code: string; + /** DDL 유형 */ + ddl_type: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN"; + /** 테이블명 */ + table_name: string; + /** 실행된 DDL 쿼리 */ + ddl_query: string; + /** 실행 성공 여부 */ + success: boolean; + /** 오류 메시지 (실패 시) */ + error_message?: string; + /** 실행 시간 */ + executed_at: Date; +} + +// PostgreSQL 타입 매핑 +export interface PostgreSQLTypeMapping { + /** 웹타입 */ + webType: WebType; + /** PostgreSQL 데이터 타입 */ + postgresType: string; + /** 기본 길이 (있는 경우) */ + defaultLength?: number; + /** 길이 지정 가능 여부 */ + supportsLength: boolean; +} + +// 테이블 메타데이터 인터페이스 +export interface TableMetadata { + /** 테이블명 */ + tableName: string; + /** 테이블 라벨 */ + tableLabel: string; + /** 테이블 설명 */ + description?: string; + /** 생성 일시 */ + createdDate: Date; + /** 수정 일시 */ + updatedDate: Date; +} + +// 컬럼 메타데이터 인터페이스 +export interface ColumnMetadata { + /** 테이블명 */ + tableName: string; + /** 컬럼명 */ + columnName: string; + /** 컬럼 라벨 */ + columnLabel: string; + /** 웹타입 */ + webType: WebType; + /** 상세 설정 (JSON 문자열) */ + detailSettings: string; + /** 컬럼 설명 */ + description?: string; + /** 표시 순서 */ + displayOrder: number; + /** 표시 여부 */ + isVisible: boolean; + /** 코드 카테고리 (code 타입용) */ + codeCategory?: string; + /** 코드 값 (code 타입용) */ + codeValue?: string; + /** 참조 테이블 (entity 타입용) */ + referenceTable?: string; + /** 참조 컬럼 (entity 타입용) */ + referenceColumn?: string; + /** 생성 일시 */ + createdDate: Date; + /** 수정 일시 */ + updatedDate: Date; +} + +// 시스템 테이블 목록 (보호 대상) +export const SYSTEM_TABLES = [ + "user_info", + "company_mng", + "menu_info", + "auth_group", + "table_labels", + "column_labels", + "screen_definitions", + "screen_layouts", + "common_code", + "multi_lang_key_master", + "multi_lang_text", + "button_action_standards", + "ddl_execution_log", +] as const; + +// 예약어 목록 +export const RESERVED_WORDS = [ + "user", + "order", + "group", + "table", + "column", + "index", + "select", + "insert", + "update", + "delete", + "from", + "where", + "join", + "on", + "as", + "and", + "or", + "not", + "null", + "true", + "false", + "create", + "alter", + "drop", + "primary", + "key", + "foreign", + "references", + "constraint", + "default", + "unique", + "check", + "view", + "procedure", + "function", +] as const; + +// 예약된 컬럼명 목록 (자동 추가되는 기본 컬럼들) +export const RESERVED_COLUMNS = [ + "id", + "created_date", + "updated_date", + "company_code", +] as const; + +// 웹타입별 PostgreSQL 타입 매핑 +export const WEB_TYPE_TO_POSTGRES_MAP: Record = + { + text: { + webType: "text", + postgresType: "varchar", + defaultLength: 255, + supportsLength: true, + }, + number: { + webType: "number", + postgresType: "integer", + supportsLength: false, + }, + decimal: { + webType: "decimal", + postgresType: "numeric(10,2)", + supportsLength: false, + }, + date: { + webType: "date", + postgresType: "date", + supportsLength: false, + }, + datetime: { + webType: "datetime", + postgresType: "timestamp", + supportsLength: false, + }, + boolean: { + webType: "boolean", + postgresType: "boolean", + supportsLength: false, + }, + code: { + webType: "code", + postgresType: "varchar", + defaultLength: 100, + supportsLength: true, + }, + entity: { + webType: "entity", + postgresType: "integer", + supportsLength: false, + }, + textarea: { + webType: "textarea", + postgresType: "text", + supportsLength: false, + }, + select: { + webType: "select", + postgresType: "varchar", + defaultLength: 100, + supportsLength: true, + }, + checkbox: { + webType: "checkbox", + postgresType: "boolean", + supportsLength: false, + }, + radio: { + webType: "radio", + postgresType: "varchar", + defaultLength: 100, + supportsLength: true, + }, + file: { + webType: "file", + postgresType: "text", + supportsLength: false, + }, + email: { + webType: "email", + postgresType: "varchar", + defaultLength: 255, + supportsLength: true, + }, + tel: { + webType: "tel", + postgresType: "varchar", + defaultLength: 50, + supportsLength: true, + }, + }; diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 6f4be6c4..298c1186 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -7,14 +7,18 @@ import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Search, Database, RefreshCw, Settings, Menu, X } from "lucide-react"; +import { Search, Database, RefreshCw, Settings, Menu, X, Plus, Activity } from "lucide-react"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; import { useMultiLang } from "@/hooks/useMultiLang"; +import { useAuth } from "@/hooks/useAuth"; import { TABLE_MANAGEMENT_KEYS, WEB_TYPE_OPTIONS_WITH_KEYS } from "@/constants/tableManagement"; import { apiClient } from "@/lib/api/client"; import { commonCodeApi } from "@/lib/api/commonCode"; import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; +import { CreateTableModal } from "@/components/admin/CreateTableModal"; +import { AddColumnModal } from "@/components/admin/AddColumnModal"; +import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; // 가상화 스크롤링을 위한 간단한 구현 interface TableInfo { @@ -45,6 +49,7 @@ interface ColumnTypeInfo { export default function TableManagementPage() { const { userLang, getText } = useMultiLang({ companyCode: "*" }); + const { user } = useAuth(); const [tables, setTables] = useState([]); const [columns, setColumns] = useState([]); const [selectedTable, setSelectedTable] = useState(null); @@ -66,6 +71,14 @@ export default function TableManagementPage() { // 🎯 Entity 조인 관련 상태 const [referenceTableColumns, setReferenceTableColumns] = useState>({}); + // DDL 기능 관련 상태 + const [createTableModalOpen, setCreateTableModalOpen] = useState(false); + const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); + const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false); + + // 최고 관리자 여부 확인 + const isSuperAdmin = user?.companyCode === "*" && user?.userId === "plm_admin"; + // 다국어 텍스트 로드 useEffect(() => { const loadTexts = async () => { @@ -554,11 +567,44 @@ export default function TableManagementPage() {

{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}

+ {isSuperAdmin && ( +

+ 🔧 최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다 +

+ )} + + +
+ {/* DDL 기능 버튼들 (최고 관리자만) */} + {isSuperAdmin && ( + <> + + + {selectedTable && ( + + )} + + + + )} + +
-
@@ -950,6 +996,47 @@ export default function TableManagementPage() {
+ + {/* DDL 모달 컴포넌트들 */} + {isSuperAdmin && ( + <> + setCreateTableModalOpen(false)} + onSuccess={async (result) => { + toast.success("테이블이 성공적으로 생성되었습니다!"); + // 테이블 목록 새로고침 + await loadTables(); + // 새로 생성된 테이블 자동 선택 및 컬럼 로드 + if (result.data?.tableName) { + setSelectedTable(result.data.tableName); + setCurrentPage(1); + setColumns([]); + await loadColumnTypes(result.data.tableName, 1, pageSize); + } + }} + /> + + setAddColumnModalOpen(false)} + tableName={selectedTable || ""} + onSuccess={async (result) => { + toast.success("컬럼이 성공적으로 추가되었습니다!"); + // 테이블 목록 새로고침 (컬럼 수 업데이트) + await loadTables(); + // 선택된 테이블의 컬럼 목록 새로고침 - 페이지 리셋 + if (selectedTable) { + setCurrentPage(1); + setColumns([]); // 기존 컬럼 목록 초기화 + await loadColumnTypes(selectedTable, 1, pageSize); + } + }} + /> + + setDdlLogViewerOpen(false)} /> + + )} ); } diff --git a/frontend/components/admin/AddColumnModal.tsx b/frontend/components/admin/AddColumnModal.tsx new file mode 100644 index 00000000..763cf5c8 --- /dev/null +++ b/frontend/components/admin/AddColumnModal.tsx @@ -0,0 +1,365 @@ +/** + * 컬럼 추가 모달 컴포넌트 + * 기존 테이블에 새로운 컬럼을 추가하기 위한 모달 + */ + +"use client"; + +import { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, Plus, AlertCircle } from "lucide-react"; +import { toast } from "sonner"; +import { ddlApi } from "../../lib/api/ddl"; +import { + AddColumnModalProps, + CreateColumnDefinition, + WEB_TYPE_OPTIONS, + VALIDATION_RULES, + RESERVED_WORDS, + RESERVED_COLUMNS, +} from "../../types/ddl"; + +export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddColumnModalProps) { + const [column, setColumn] = useState({ + name: "", + label: "", + webType: "text", + nullable: true, + order: 0, + }); + const [loading, setLoading] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + + /** + * 모달 리셋 + */ + const resetModal = () => { + setColumn({ + name: "", + label: "", + webType: "text", + nullable: true, + order: 0, + }); + setValidationErrors([]); + }; + + /** + * 모달 열림/닫힘 시 리셋 + */ + useEffect(() => { + if (isOpen) { + resetModal(); + } + }, [isOpen]); + + /** + * 컬럼 정보 업데이트 + */ + const updateColumn = (updates: Partial) => { + const newColumn = { ...column, ...updates }; + setColumn(newColumn); + + // 업데이트 후 검증 + validateColumn(newColumn); + }; + + /** + * 컬럼 검증 + */ + const validateColumn = (columnData: CreateColumnDefinition) => { + const errors: string[] = []; + + // 컬럼명 검증 + if (!columnData.name) { + errors.push("컬럼명은 필수입니다."); + } else { + if (!VALIDATION_RULES.columnName.pattern.test(columnData.name)) { + errors.push(VALIDATION_RULES.columnName.errorMessage); + } + + if ( + columnData.name.length < VALIDATION_RULES.columnName.minLength || + columnData.name.length > VALIDATION_RULES.columnName.maxLength + ) { + errors.push( + `컬럼명은 ${VALIDATION_RULES.columnName.minLength}-${VALIDATION_RULES.columnName.maxLength}자여야 합니다.`, + ); + } + + // 예약어 검증 + if (RESERVED_WORDS.includes(columnData.name.toLowerCase() as any)) { + errors.push("SQL 예약어는 컬럼명으로 사용할 수 없습니다."); + } + + // 예약된 컬럼명 검증 + if (RESERVED_COLUMNS.includes(columnData.name.toLowerCase() as any)) { + errors.push("이미 자동 추가되는 기본 컬럼명입니다."); + } + + // 네이밍 컨벤션 검증 + if (columnData.name.startsWith("_") || columnData.name.endsWith("_")) { + errors.push("컬럼명은 언더스코어로 시작하거나 끝날 수 없습니다."); + } + + if (columnData.name.includes("__")) { + errors.push("컬럼명에 연속된 언더스코어는 사용할 수 없습니다."); + } + } + + // 웹타입 검증 + if (!columnData.webType) { + errors.push("웹타입을 선택해주세요."); + } + + // 길이 검증 (길이를 지원하는 타입인 경우) + const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === columnData.webType); + if (webTypeOption?.supportsLength && columnData.length !== undefined) { + if ( + columnData.length < VALIDATION_RULES.columnLength.min || + columnData.length > VALIDATION_RULES.columnLength.max + ) { + errors.push(VALIDATION_RULES.columnLength.errorMessage); + } + } + + setValidationErrors(errors); + return errors.length === 0; + }; + + /** + * 웹타입 변경 처리 + */ + const handleWebTypeChange = (webType: string) => { + const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === webType); + const updates: Partial = { webType: webType as any }; + + // 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정 + if (webTypeOption?.supportsLength && !column.length && webTypeOption.defaultLength) { + updates.length = webTypeOption.defaultLength; + } + + // 길이를 지원하지 않는 타입이면 길이 제거 + if (!webTypeOption?.supportsLength) { + updates.length = undefined; + } + + updateColumn(updates); + }; + + /** + * 컬럼 추가 실행 + */ + const handleAddColumn = async () => { + if (!validateColumn(column)) { + toast.error("입력값을 확인해주세요."); + return; + } + + setLoading(true); + try { + const result = await ddlApi.addColumn(tableName, { column }); + + if (result.success) { + toast.success(result.message); + onSuccess(result); + onClose(); + } else { + toast.error(result.error?.details || result.message); + } + } catch (error: any) { + console.error("컬럼 추가 실패:", error); + toast.error(error.response?.data?.error?.details || "컬럼 추가에 실패했습니다."); + } finally { + setLoading(false); + } + }; + + /** + * 폼 유효성 확인 + */ + const isFormValid = validationErrors.length === 0 && column.name && column.webType; + + const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === column.webType); + + return ( + + + + + + 컬럼 추가 - {tableName} + + + +
+ {/* 검증 오류 표시 */} + {validationErrors.length > 0 && ( + + + +
+ {validationErrors.map((error, index) => ( +
• {error}
+ ))} +
+
+
+ )} + + {/* 기본 정보 */} +
+
+ + updateColumn({ name: e.target.value })} + placeholder="column_name" + disabled={loading} + className={validationErrors.some((e) => e.includes("컬럼명")) ? "border-red-300" : ""} + /> +

영문자로 시작, 영문자/숫자/언더스코어만 사용 가능

+
+ +
+ + updateColumn({ label: e.target.value })} + placeholder="컬럼 라벨" + disabled={loading} + /> +

화면에 표시될 라벨 (선택사항)

+
+
+ + {/* 타입 및 속성 */} +
+
+ + +
+ +
+ + + updateColumn({ + length: e.target.value ? parseInt(e.target.value) : undefined, + }) + } + placeholder={webTypeOption?.defaultLength?.toString() || ""} + disabled={loading || !webTypeOption?.supportsLength} + min={1} + max={65535} + /> +

+ {webTypeOption?.supportsLength ? "1-65535 범위에서 설정 가능" : "이 타입은 길이 설정이 불가능합니다"} +

+
+
+ + {/* 기본값 및 NULL 허용 */} +
+
+ + updateColumn({ defaultValue: e.target.value })} + placeholder="기본값 (선택사항)" + disabled={loading} + /> +
+ +
+ updateColumn({ nullable: !checked })} + disabled={loading} + /> + +
+
+ + {/* 설명 */} +
+ +