테이블 및 컬럼 생성기능 추가
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
407
backend-node/src/controllers/ddlController.ts
Normal file
407
backend-node/src/controllers/ddlController.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 로그 정리 중 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
200
backend-node/src/middleware/superAdminMiddleware.ts
Normal file
200
backend-node/src/middleware/superAdminMiddleware.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 슈퍼관리자 권한 검증 미들웨어
|
||||
* 회사코드가 '*'인 최고 관리자만 DDL 실행을 허용
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// DDL 요청 시간 추적을 위한 메모리 저장소
|
||||
const ddlRequestTimes = new Map<string, number>();
|
||||
|
||||
// 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 };
|
||||
};
|
||||
215
backend-node/src/routes/ddlRoutes.ts
Normal file
215
backend-node/src/routes/ddlRoutes.ts
Normal file
@@ -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;
|
||||
368
backend-node/src/services/ddlAuditLogger.ts
Normal file
368
backend-node/src/services/ddlAuditLogger.ts
Normal file
@@ -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<string, any>
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
logger.info("DDL 실행 시작", {
|
||||
userId,
|
||||
companyCode,
|
||||
ddlType,
|
||||
tableName,
|
||||
requestData,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 DDL 실행 로그 조회
|
||||
*/
|
||||
static async getRecentDDLLogs(
|
||||
limit: number = 50,
|
||||
userId?: string,
|
||||
ddlType?: string
|
||||
): Promise<any[]> {
|
||||
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<string, number>;
|
||||
byUser: Record<string, number>;
|
||||
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<any[]> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
625
backend-node/src/services/ddlExecutionService.ts
Normal file
625
backend-node/src/services/ddlExecutionService.ts
Normal file
@@ -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<DDLExecutionResult> {
|
||||
// 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<DDLExecutionResult> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
// 먼저 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<boolean> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
390
backend-node/src/services/ddlSafetyValidator.ts
Normal file
390
backend-node/src/services/ddlSafetyValidator.ts
Normal file
@@ -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<string>();
|
||||
const duplicates = new Set<string>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
314
backend-node/src/types/ddl.ts
Normal file
314
backend-node/src/types/ddl.ts
Normal file
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
// 테이블 생성 요청 인터페이스
|
||||
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<WebType, PostgreSQLTypeMapping> =
|
||||
{
|
||||
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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user