테이블 및 컬럼 생성기능 추가
This commit is contained in:
parent
0258c2a76c
commit
dfda1bcc24
|
|
@ -31,6 +31,7 @@ import layoutRoutes from "./routes/layoutRoutes";
|
||||||
import dataRoutes from "./routes/dataRoutes";
|
import dataRoutes from "./routes/dataRoutes";
|
||||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||||
|
import ddlRoutes from "./routes/ddlRoutes";
|
||||||
// import userRoutes from './routes/userRoutes';
|
// import userRoutes from './routes/userRoutes';
|
||||||
// import menuRoutes from './routes/menuRoutes';
|
// import menuRoutes from './routes/menuRoutes';
|
||||||
|
|
||||||
|
|
@ -125,6 +126,7 @@ app.use("/api/screen", screenStandardRoutes);
|
||||||
app.use("/api/data", dataRoutes);
|
app.use("/api/data", dataRoutes);
|
||||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||||
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||||
|
app.use("/api/ddl", ddlRoutes);
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
// app.use('/api/menus', menuRoutes);
|
// app.use('/api/menus', menuRoutes);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 로그 정리 중 오류가 발생했습니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -7,14 +7,18 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { TABLE_MANAGEMENT_KEYS, WEB_TYPE_OPTIONS_WITH_KEYS } from "@/constants/tableManagement";
|
import { TABLE_MANAGEMENT_KEYS, WEB_TYPE_OPTIONS_WITH_KEYS } from "@/constants/tableManagement";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
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 {
|
interface TableInfo {
|
||||||
|
|
@ -45,6 +49,7 @@ interface ColumnTypeInfo {
|
||||||
|
|
||||||
export default function TableManagementPage() {
|
export default function TableManagementPage() {
|
||||||
const { userLang, getText } = useMultiLang({ companyCode: "*" });
|
const { userLang, getText } = useMultiLang({ companyCode: "*" });
|
||||||
|
const { user } = useAuth();
|
||||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
|
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
|
||||||
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||||
|
|
@ -66,6 +71,14 @@ export default function TableManagementPage() {
|
||||||
// 🎯 Entity 조인 관련 상태
|
// 🎯 Entity 조인 관련 상태
|
||||||
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
|
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
const loadTexts = async () => {
|
const loadTexts = async () => {
|
||||||
|
|
@ -554,11 +567,44 @@ export default function TableManagementPage() {
|
||||||
<p className="mt-2 text-gray-600">
|
<p className="mt-2 text-gray-600">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}
|
||||||
</p>
|
</p>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<p className="mt-1 text-sm font-medium text-blue-600">
|
||||||
|
🔧 최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* DDL 기능 버튼들 (최고 관리자만) */}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => setCreateTableModalOpen(true)}
|
||||||
|
className="bg-green-600 text-white hover:bg-green-700"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 테이블 생성
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{selectedTable && (
|
||||||
|
<Button onClick={() => setAddColumnModalOpen(true)} variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
컬럼 추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={() => setDdlLogViewerOpen(true)} variant="outline" size="sm">
|
||||||
|
<Activity className="mr-2 h-4 w-4" />
|
||||||
|
DDL 로그
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={loadTables} disabled={loading} className="flex items-center gap-2" size="sm">
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={loadTables} disabled={loading} className="flex items-center gap-2">
|
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||||
|
|
@ -950,6 +996,47 @@ export default function TableManagementPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* DDL 모달 컴포넌트들 */}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<>
|
||||||
|
<CreateTableModal
|
||||||
|
isOpen={createTableModalOpen}
|
||||||
|
onClose={() => 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddColumnModal
|
||||||
|
isOpen={addColumnModalOpen}
|
||||||
|
onClose={() => setAddColumnModalOpen(false)}
|
||||||
|
tableName={selectedTable || ""}
|
||||||
|
onSuccess={async (result) => {
|
||||||
|
toast.success("컬럼이 성공적으로 추가되었습니다!");
|
||||||
|
// 테이블 목록 새로고침 (컬럼 수 업데이트)
|
||||||
|
await loadTables();
|
||||||
|
// 선택된 테이블의 컬럼 목록 새로고침 - 페이지 리셋
|
||||||
|
if (selectedTable) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setColumns([]); // 기존 컬럼 목록 초기화
|
||||||
|
await loadColumnTypes(selectedTable, 1, pageSize);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<CreateColumnDefinition>({
|
||||||
|
name: "",
|
||||||
|
label: "",
|
||||||
|
webType: "text",
|
||||||
|
nullable: true,
|
||||||
|
order: 0,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 리셋
|
||||||
|
*/
|
||||||
|
const resetModal = () => {
|
||||||
|
setColumn({
|
||||||
|
name: "",
|
||||||
|
label: "",
|
||||||
|
webType: "text",
|
||||||
|
nullable: true,
|
||||||
|
order: 0,
|
||||||
|
});
|
||||||
|
setValidationErrors([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 열림/닫힘 시 리셋
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
resetModal();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 정보 업데이트
|
||||||
|
*/
|
||||||
|
const updateColumn = (updates: Partial<CreateColumnDefinition>) => {
|
||||||
|
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<CreateColumnDefinition> = { 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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
컬럼 추가 - {tableName}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검증 오류 표시 */}
|
||||||
|
{validationErrors.length > 0 && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{validationErrors.map((error, index) => (
|
||||||
|
<div key={index}>• {error}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="columnName">
|
||||||
|
컬럼명 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="columnName"
|
||||||
|
value={column.name}
|
||||||
|
onChange={(e) => updateColumn({ name: e.target.value })}
|
||||||
|
placeholder="column_name"
|
||||||
|
disabled={loading}
|
||||||
|
className={validationErrors.some((e) => e.includes("컬럼명")) ? "border-red-300" : ""}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="columnLabel">라벨</Label>
|
||||||
|
<Input
|
||||||
|
id="columnLabel"
|
||||||
|
value={column.label || ""}
|
||||||
|
onChange={(e) => updateColumn({ label: e.target.value })}
|
||||||
|
placeholder="컬럼 라벨"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">화면에 표시될 라벨 (선택사항)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타입 및 속성 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
웹타입 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select value={column.webType} onValueChange={handleWebTypeChange} disabled={loading}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="웹타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{WEB_TYPE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{option.label}</div>
|
||||||
|
{option.description && (
|
||||||
|
<div className="text-muted-foreground text-xs">{option.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="columnLength">길이</Label>
|
||||||
|
<Input
|
||||||
|
id="columnLength"
|
||||||
|
type="number"
|
||||||
|
value={column.length || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateColumn({
|
||||||
|
length: e.target.value ? parseInt(e.target.value) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={webTypeOption?.defaultLength?.toString() || ""}
|
||||||
|
disabled={loading || !webTypeOption?.supportsLength}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{webTypeOption?.supportsLength ? "1-65535 범위에서 설정 가능" : "이 타입은 길이 설정이 불가능합니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본값 및 NULL 허용 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="defaultValue">기본값</Label>
|
||||||
|
<Input
|
||||||
|
id="defaultValue"
|
||||||
|
value={column.defaultValue || ""}
|
||||||
|
onChange={(e) => updateColumn({ defaultValue: e.target.value })}
|
||||||
|
placeholder="기본값 (선택사항)"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 pt-6">
|
||||||
|
<Checkbox
|
||||||
|
id="required"
|
||||||
|
checked={!column.nullable}
|
||||||
|
onCheckedChange={(checked) => updateColumn({ nullable: !checked })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="required" className="text-sm font-medium">
|
||||||
|
필수 입력 (NOT NULL)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">설명</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={column.description || ""}
|
||||||
|
onChange={(e) => updateColumn({ description: e.target.value })}
|
||||||
|
placeholder="컬럼에 대한 설명 (선택사항)"
|
||||||
|
disabled={loading}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 안내 사항 */}
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
컬럼 추가 후에는 해당 컬럼을 삭제하거나 타입을 변경할 수 없습니다. 신중하게 검토 후 추가해주세요.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleAddColumn}
|
||||||
|
disabled={!isFormValid || loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
추가 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"컬럼 추가"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,318 @@
|
||||||
|
/**
|
||||||
|
* 컬럼 정의 테이블 컴포넌트
|
||||||
|
* 테이블 생성 시 컬럼 정의를 위한 편집 가능한 테이블
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { X, AlertCircle } from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
CreateColumnDefinition,
|
||||||
|
ColumnDefinitionTableProps,
|
||||||
|
WEB_TYPE_OPTIONS,
|
||||||
|
VALIDATION_RULES,
|
||||||
|
RESERVED_WORDS,
|
||||||
|
RESERVED_COLUMNS,
|
||||||
|
} from "../../types/ddl";
|
||||||
|
|
||||||
|
export function ColumnDefinitionTable({ columns, onChange, disabled = false }: ColumnDefinitionTableProps) {
|
||||||
|
const [validationErrors, setValidationErrors] = useState<Record<number, string[]>>({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 정보 업데이트
|
||||||
|
*/
|
||||||
|
const updateColumn = (index: number, updates: Partial<CreateColumnDefinition>) => {
|
||||||
|
const newColumns = [...columns];
|
||||||
|
newColumns[index] = { ...newColumns[index], ...updates };
|
||||||
|
onChange(newColumns);
|
||||||
|
|
||||||
|
// 업데이트 후 해당 컬럼 검증
|
||||||
|
validateColumn(index, newColumns[index]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 제거
|
||||||
|
*/
|
||||||
|
const removeColumn = (index: number) => {
|
||||||
|
if (columns.length <= 1) return; // 최소 1개 컬럼 유지
|
||||||
|
|
||||||
|
const newColumns = columns.filter((_, i) => i !== index);
|
||||||
|
onChange(newColumns);
|
||||||
|
|
||||||
|
// 검증 오류도 함께 제거
|
||||||
|
const newErrors = { ...validationErrors };
|
||||||
|
delete newErrors[index];
|
||||||
|
|
||||||
|
// 인덱스 재조정
|
||||||
|
const adjustedErrors: Record<number, string[]> = {};
|
||||||
|
Object.entries(newErrors).forEach(([key, value]) => {
|
||||||
|
const idx = parseInt(key);
|
||||||
|
if (idx > index) {
|
||||||
|
adjustedErrors[idx - 1] = value;
|
||||||
|
} else if (idx < index) {
|
||||||
|
adjustedErrors[idx] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidationErrors(adjustedErrors);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 컬럼 검증
|
||||||
|
*/
|
||||||
|
const validateColumn = (index: number, column: CreateColumnDefinition) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 컬럼명 검증
|
||||||
|
if (!column.name) {
|
||||||
|
errors.push("컬럼명은 필수입니다");
|
||||||
|
} else {
|
||||||
|
if (!VALIDATION_RULES.columnName.pattern.test(column.name)) {
|
||||||
|
errors.push(VALIDATION_RULES.columnName.errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
column.name.length < VALIDATION_RULES.columnName.minLength ||
|
||||||
|
column.name.length > VALIDATION_RULES.columnName.maxLength
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`컬럼명은 ${VALIDATION_RULES.columnName.minLength}-${VALIDATION_RULES.columnName.maxLength}자여야 합니다`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 예약어 검증
|
||||||
|
if (RESERVED_WORDS.includes(column.name.toLowerCase() as any)) {
|
||||||
|
errors.push("예약어는 컬럼명으로 사용할 수 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 예약된 컬럼명 검증
|
||||||
|
if (RESERVED_COLUMNS.includes(column.name.toLowerCase() as any)) {
|
||||||
|
errors.push("이미 자동 추가되는 기본 컬럼명입니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 검증
|
||||||
|
const duplicateCount = columns.filter((col) => col.name.toLowerCase() === column.name.toLowerCase()).length;
|
||||||
|
if (duplicateCount > 1) {
|
||||||
|
errors.push("중복된 컬럼명입니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 웹타입 검증
|
||||||
|
if (!column.webType) {
|
||||||
|
errors.push("웹타입을 선택해주세요");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 길이 검증 (길이를 지원하는 타입인 경우)
|
||||||
|
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === column.webType);
|
||||||
|
if (webTypeOption?.supportsLength && column.length !== undefined) {
|
||||||
|
if (column.length < VALIDATION_RULES.columnLength.min || column.length > VALIDATION_RULES.columnLength.max) {
|
||||||
|
errors.push(VALIDATION_RULES.columnLength.errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검증 오류 상태 업데이트
|
||||||
|
setValidationErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[index]: errors,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return errors.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 웹타입 변경 시 길이 기본값 설정
|
||||||
|
*/
|
||||||
|
const handleWebTypeChange = (index: number, webType: string) => {
|
||||||
|
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === webType);
|
||||||
|
const updates: Partial<CreateColumnDefinition> = { webType: webType as any };
|
||||||
|
|
||||||
|
// 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정
|
||||||
|
if (webTypeOption?.supportsLength && !columns[index].length && webTypeOption.defaultLength) {
|
||||||
|
updates.length = webTypeOption.defaultLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 길이를 지원하지 않는 타입이면 길이 제거
|
||||||
|
if (!webTypeOption?.supportsLength) {
|
||||||
|
updates.length = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateColumn(index, updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 검증 상태 확인
|
||||||
|
*/
|
||||||
|
const hasValidationErrors = Object.values(validationErrors).some((errors) => errors.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 검증 오류 요약 */}
|
||||||
|
{hasValidationErrors && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>컬럼 정의에 오류가 있습니다. 각 컬럼의 오류를 확인해주세요.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컬럼 정의 테이블 */}
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[150px]">
|
||||||
|
컬럼명 <span className="text-red-500">*</span>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[150px]">라벨</TableHead>
|
||||||
|
<TableHead className="w-[120px]">
|
||||||
|
웹타입 <span className="text-red-500">*</span>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[80px]">필수</TableHead>
|
||||||
|
<TableHead className="w-[100px]">길이</TableHead>
|
||||||
|
<TableHead className="w-[120px]">기본값</TableHead>
|
||||||
|
<TableHead>설명</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{columns.map((column, index) => {
|
||||||
|
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === column.webType);
|
||||||
|
const rowErrors = validationErrors[index] || [];
|
||||||
|
const hasRowError = rowErrors.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={index} className={hasRowError ? "bg-red-50" : ""}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
value={column.name}
|
||||||
|
onChange={(e) => updateColumn(index, { name: e.target.value })}
|
||||||
|
placeholder="column_name"
|
||||||
|
disabled={disabled}
|
||||||
|
className={hasRowError ? "border-red-300" : ""}
|
||||||
|
/>
|
||||||
|
{rowErrors.length > 0 && (
|
||||||
|
<div className="space-y-1 text-xs text-red-600">
|
||||||
|
{rowErrors.map((error, i) => (
|
||||||
|
<div key={i}>{error}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={column.label || ""}
|
||||||
|
onChange={(e) => updateColumn(index, { label: e.target.value })}
|
||||||
|
placeholder="컬럼 라벨"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={column.webType}
|
||||||
|
onValueChange={(value) => handleWebTypeChange(index, value)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{WEB_TYPE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{option.label}</div>
|
||||||
|
{option.description && (
|
||||||
|
<div className="text-muted-foreground text-xs">{option.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={!column.nullable}
|
||||||
|
onCheckedChange={(checked) => updateColumn(index, { nullable: !checked })}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={column.length || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateColumn(index, {
|
||||||
|
length: e.target.value ? parseInt(e.target.value) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={webTypeOption?.defaultLength?.toString() || ""}
|
||||||
|
disabled={disabled || !webTypeOption?.supportsLength}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={column.defaultValue || ""}
|
||||||
|
onChange={(e) => updateColumn(index, { defaultValue: e.target.value })}
|
||||||
|
placeholder="기본값"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Textarea
|
||||||
|
value={column.description || ""}
|
||||||
|
onChange={(e) => updateColumn(index, { description: e.target.value })}
|
||||||
|
placeholder="컬럼 설명"
|
||||||
|
disabled={disabled}
|
||||||
|
rows={1}
|
||||||
|
className="min-h-[36px] resize-none"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeColumn(index)}
|
||||||
|
disabled={disabled || columns.length === 1}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 도움말 */}
|
||||||
|
<div className="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<div>• 컬럼명은 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.</div>
|
||||||
|
<div>• 다음 컬럼들은 자동으로 추가됩니다: id (PK), created_date, updated_date, company_code</div>
|
||||||
|
<div>• 필수 컬럼은 NOT NULL 제약조건이 추가됩니다.</div>
|
||||||
|
<div>• 길이는 text, code, email, tel 등의 타입에서만 설정 가능합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,364 @@
|
||||||
|
/**
|
||||||
|
* 테이블 생성 모달 컴포넌트
|
||||||
|
* 새로운 테이블을 생성하기 위한 모달
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Loader2, Info, AlertCircle, CheckCircle2, Plus } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ColumnDefinitionTable } from "./ColumnDefinitionTable";
|
||||||
|
import { ddlApi } from "../../lib/api/ddl";
|
||||||
|
import {
|
||||||
|
CreateTableModalProps,
|
||||||
|
CreateColumnDefinition,
|
||||||
|
VALIDATION_RULES,
|
||||||
|
SYSTEM_TABLES,
|
||||||
|
RESERVED_WORDS,
|
||||||
|
} from "../../types/ddl";
|
||||||
|
|
||||||
|
export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModalProps) {
|
||||||
|
const [tableName, setTableName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [columns, setColumns] = useState<CreateColumnDefinition[]>([
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
label: "",
|
||||||
|
webType: "text",
|
||||||
|
nullable: true,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [validating, setValidating] = useState(false);
|
||||||
|
const [tableNameError, setTableNameError] = useState("");
|
||||||
|
const [validationResult, setValidationResult] = useState<any>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 리셋
|
||||||
|
*/
|
||||||
|
const resetModal = () => {
|
||||||
|
setTableName("");
|
||||||
|
setDescription("");
|
||||||
|
setColumns([
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
label: "",
|
||||||
|
webType: "text",
|
||||||
|
nullable: true,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setTableNameError("");
|
||||||
|
setValidationResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 열림/닫힘 시 리셋
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
resetModal();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블명 검증
|
||||||
|
*/
|
||||||
|
const validateTableName = (name: string): string => {
|
||||||
|
if (!name) {
|
||||||
|
return "테이블명은 필수입니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALIDATION_RULES.tableName.pattern.test(name)) {
|
||||||
|
return VALIDATION_RULES.tableName.errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length < VALIDATION_RULES.tableName.minLength || name.length > VALIDATION_RULES.tableName.maxLength) {
|
||||||
|
return `테이블명은 ${VALIDATION_RULES.tableName.minLength}-${VALIDATION_RULES.tableName.maxLength}자여야 합니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SYSTEM_TABLES.includes(name.toLowerCase() as any)) {
|
||||||
|
return "시스템 테이블명으로 사용할 수 없습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RESERVED_WORDS.includes(name.toLowerCase() as any)) {
|
||||||
|
return "SQL 예약어는 테이블명으로 사용할 수 없습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith("_") || name.endsWith("_")) {
|
||||||
|
return "테이블명은 언더스코어로 시작하거나 끝날 수 없습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes("__")) {
|
||||||
|
return "테이블명에 연속된 언더스코어는 사용할 수 없습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블명 변경 처리
|
||||||
|
*/
|
||||||
|
const handleTableNameChange = (value: string) => {
|
||||||
|
setTableName(value);
|
||||||
|
const error = validateTableName(value);
|
||||||
|
setTableNameError(error);
|
||||||
|
|
||||||
|
// 검증 결과 초기화
|
||||||
|
if (validationResult) {
|
||||||
|
setValidationResult(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 추가
|
||||||
|
*/
|
||||||
|
const addColumn = () => {
|
||||||
|
setColumns([
|
||||||
|
...columns,
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
label: "",
|
||||||
|
webType: "text",
|
||||||
|
nullable: true,
|
||||||
|
order: columns.length + 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 생성 사전 검증
|
||||||
|
*/
|
||||||
|
const validateTable = async () => {
|
||||||
|
if (tableNameError || !tableName) {
|
||||||
|
toast.error("테이블명을 올바르게 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validColumns = columns.filter((col) => col.name && col.webType);
|
||||||
|
if (validColumns.length === 0) {
|
||||||
|
toast.error("최소 1개의 유효한 컬럼이 필요합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidating(true);
|
||||||
|
try {
|
||||||
|
const result = await ddlApi.validateTableCreation({
|
||||||
|
tableName,
|
||||||
|
columns: validColumns,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidationResult(result);
|
||||||
|
|
||||||
|
if (result.isValid) {
|
||||||
|
toast.success("검증 완료! 테이블을 생성할 수 있습니다.");
|
||||||
|
} else {
|
||||||
|
toast.error("검증 실패. 오류를 확인해주세요.");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("테이블 검증 실패:", error);
|
||||||
|
toast.error("검증 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 생성 실행
|
||||||
|
*/
|
||||||
|
const handleCreateTable = async () => {
|
||||||
|
if (tableNameError || !tableName) {
|
||||||
|
toast.error("테이블명을 올바르게 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validColumns = columns.filter((col) => col.name && col.webType);
|
||||||
|
if (validColumns.length === 0) {
|
||||||
|
toast.error("최소 1개의 유효한 컬럼이 필요합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await ddlApi.createTable({
|
||||||
|
tableName,
|
||||||
|
columns: validColumns,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = !tableNameError && tableName && columns.some((col) => col.name && col.webType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Plus className="h-5 w-5" />새 테이블 생성
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 테이블 기본 정보 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tableName">
|
||||||
|
테이블명 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tableName"
|
||||||
|
value={tableName}
|
||||||
|
onChange={(e) => handleTableNameChange(e.target.value)}
|
||||||
|
placeholder="예: customer_info"
|
||||||
|
className={tableNameError ? "border-red-300" : ""}
|
||||||
|
/>
|
||||||
|
{tableNameError && <p className="text-sm text-red-600">{tableNameError}</p>}
|
||||||
|
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">설명</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="테이블에 대한 설명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 정의 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>
|
||||||
|
컬럼 정의 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addColumn} disabled={loading}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자동 추가 컬럼 안내 */}
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>자동 추가 컬럼</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
다음 컬럼들이 자동으로 추가됩니다:
|
||||||
|
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">id</code>(기본키),
|
||||||
|
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">created_date</code>,
|
||||||
|
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">updated_date</code>,
|
||||||
|
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">company_code</code>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* 검증 결과 */}
|
||||||
|
{validationResult && (
|
||||||
|
<Alert variant={validationResult.isValid ? "default" : "destructive"}>
|
||||||
|
{validationResult.isValid ? <CheckCircle2 className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
|
||||||
|
<AlertTitle>{validationResult.isValid ? "검증 성공" : "검증 실패"}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<div>{validationResult.summary}</div>
|
||||||
|
{validationResult.errors && validationResult.errors.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="font-medium">오류:</div>
|
||||||
|
<ul className="list-inside list-disc space-y-1">
|
||||||
|
{validationResult.errors.map((error: string, index: number) => (
|
||||||
|
<li key={index} className="text-sm">
|
||||||
|
{error}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{validationResult.warnings && validationResult.warnings.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="font-medium">경고:</div>
|
||||||
|
<ul className="list-inside list-disc space-y-1">
|
||||||
|
{validationResult.warnings.map((warning: string, index: number) => (
|
||||||
|
<li key={index} className="text-sm text-orange-600">
|
||||||
|
{warning}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="secondary" onClick={validateTable} disabled={!isFormValid || validating || loading}>
|
||||||
|
{validating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
검증 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"검증하기"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateTable}
|
||||||
|
disabled={!isFormValid || loading || (validationResult && !validationResult.isValid)}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
생성 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"테이블 생성"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,407 @@
|
||||||
|
/**
|
||||||
|
* DDL 로그 뷰어 컴포넌트
|
||||||
|
* DDL 실행 로그와 통계를 표시
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Database,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
BarChart3,
|
||||||
|
Clock,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
import { ddlApi } from "../../lib/api/ddl";
|
||||||
|
import { DDLLogViewerProps, DDLExecutionLog, DDLStatistics } from "../../types/ddl";
|
||||||
|
|
||||||
|
export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
|
const [logs, setLogs] = useState<DDLExecutionLog[]>([]);
|
||||||
|
const [statistics, setStatistics] = useState<DDLStatistics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
const [userFilter, setUserFilter] = useState("");
|
||||||
|
const [ddlTypeFilter, setDdlTypeFilter] = useState("all");
|
||||||
|
const [limit, setLimit] = useState(50);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 및 통계 로드
|
||||||
|
*/
|
||||||
|
const loadData = async (showLoading = true) => {
|
||||||
|
if (showLoading) setLoading(true);
|
||||||
|
setRefreshing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 로그와 통계를 병렬로 로드
|
||||||
|
const [logsResult, statsResult] = await Promise.all([
|
||||||
|
ddlApi.getDDLLogs({
|
||||||
|
limit,
|
||||||
|
userId: userFilter || undefined,
|
||||||
|
ddlType: ddlTypeFilter === "all" ? undefined : ddlTypeFilter,
|
||||||
|
}),
|
||||||
|
ddlApi.getDDLStatistics(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setLogs(logsResult.logs);
|
||||||
|
setStatistics(statsResult);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("DDL 로그 로드 실패:", error);
|
||||||
|
toast.error("DDL 로그를 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
if (showLoading) setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 적용
|
||||||
|
*/
|
||||||
|
const applyFilters = () => {
|
||||||
|
loadData(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 초기화
|
||||||
|
*/
|
||||||
|
const resetFilters = () => {
|
||||||
|
setUserFilter("");
|
||||||
|
setDdlTypeFilter("");
|
||||||
|
setLimit(50);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 정리
|
||||||
|
*/
|
||||||
|
const cleanupLogs = async () => {
|
||||||
|
if (!confirm("90일 이전의 오래된 로그를 삭제하시겠습니까?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ddlApi.cleanupOldLogs(90);
|
||||||
|
toast.success(`${result.deletedCount}개의 오래된 로그가 삭제되었습니다.`);
|
||||||
|
loadData(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("로그 정리 실패:", error);
|
||||||
|
toast.error("로그 정리에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트 마운트 시 데이터 로드
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDL 타입 배지 색상
|
||||||
|
*/
|
||||||
|
const getDDLTypeBadgeVariant = (ddlType: string) => {
|
||||||
|
switch (ddlType) {
|
||||||
|
case "CREATE_TABLE":
|
||||||
|
return "default";
|
||||||
|
case "ADD_COLUMN":
|
||||||
|
return "secondary";
|
||||||
|
case "DROP_TABLE":
|
||||||
|
return "destructive";
|
||||||
|
case "DROP_COLUMN":
|
||||||
|
return "outline";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공률 계산
|
||||||
|
*/
|
||||||
|
const getSuccessRate = (stats: DDLStatistics) => {
|
||||||
|
if (stats.totalExecutions === 0) return 0;
|
||||||
|
return Math.round((stats.successfulExecutions / stats.totalExecutions) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
DDL 실행 로그 및 통계
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs defaultValue="logs" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="logs">실행 로그</TabsTrigger>
|
||||||
|
<TabsTrigger value="statistics">통계</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 실행 로그 탭 */}
|
||||||
|
<TabsContent value="logs" className="space-y-4">
|
||||||
|
{/* 필터 및 컨트롤 */}
|
||||||
|
<div className="bg-muted/50 flex flex-wrap items-center gap-2 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="사용자 ID"
|
||||||
|
value={userFilter}
|
||||||
|
onChange={(e) => setUserFilter(e.target.value)}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select value={ddlTypeFilter} onValueChange={setDdlTypeFilter}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="DDL 타입" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="CREATE_TABLE">테이블 생성</SelectItem>
|
||||||
|
<SelectItem value="ADD_COLUMN">컬럼 추가</SelectItem>
|
||||||
|
<SelectItem value="DROP_TABLE">테이블 삭제</SelectItem>
|
||||||
|
<SelectItem value="DROP_COLUMN">컬럼 삭제</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={limit.toString()} onValueChange={(value) => setLimit(parseInt(value))}>
|
||||||
|
<SelectTrigger className="w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="25">25개</SelectItem>
|
||||||
|
<SelectItem value="50">50개</SelectItem>
|
||||||
|
<SelectItem value="100">100개</SelectItem>
|
||||||
|
<SelectItem value="200">200개</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button onClick={applyFilters} size="sm">
|
||||||
|
<Search className="mr-1 h-4 w-4" />
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={resetFilters} variant="outline" size="sm">
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Button onClick={() => loadData(false)} disabled={refreshing} variant="outline" size="sm">
|
||||||
|
<RefreshCw className={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={cleanupLogs} variant="outline" size="sm">
|
||||||
|
<Trash2 className="mr-1 h-4 w-4" />
|
||||||
|
로그 정리
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그 테이블 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="mr-2 h-6 w-6 animate-spin" />
|
||||||
|
로그를 불러오는 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>실행 시간</TableHead>
|
||||||
|
<TableHead>사용자</TableHead>
|
||||||
|
<TableHead>DDL 타입</TableHead>
|
||||||
|
<TableHead>테이블명</TableHead>
|
||||||
|
<TableHead>결과</TableHead>
|
||||||
|
<TableHead>쿼리 미리보기</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
|
||||||
|
표시할 로그가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{format(new Date(log.executed_at), "yyyy-MM-dd HH:mm:ss", { locale: ko })}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{log.user_id}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={getDDLTypeBadgeVariant(log.ddl_type)}>{log.ddl_type}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<code className="bg-muted rounded px-2 py-1 text-sm">{log.table_name}</code>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{log.success ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
<span className={log.success ? "text-green-600" : "text-red-600"}>
|
||||||
|
{log.success ? "성공" : "실패"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{log.error_message && (
|
||||||
|
<div className="mt-1 max-w-xs truncate text-xs text-red-600">{log.error_message}</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-muted-foreground block max-w-xs truncate text-xs">
|
||||||
|
{log.ddl_query_preview}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 통계 탭 */}
|
||||||
|
<TabsContent value="statistics" className="space-y-4">
|
||||||
|
{statistics && (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{/* 전체 통계 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">전체 실행</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{statistics.totalExecutions}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{statistics.successfulExecutions}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-red-600">{statistics.failedExecutions}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">성공률</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{getSuccessRate(statistics)}%</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DDL 타입별 통계 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">DDL 타입별 실행 횟수</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{Object.entries(statistics.byDDLType).map(([type, count]) => (
|
||||||
|
<div key={type} className="flex items-center justify-between">
|
||||||
|
<Badge variant={getDDLTypeBadgeVariant(type)}>{type}</Badge>
|
||||||
|
<span className="font-medium">{count}회</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">사용자별 실행 횟수</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{Object.entries(statistics.byUser).map(([user, count]) => (
|
||||||
|
<div key={user} className="flex items-center justify-between">
|
||||||
|
<Badge variant="outline">{user}</Badge>
|
||||||
|
<span className="font-medium">{count}회</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최근 실패 로그 */}
|
||||||
|
{statistics.recentFailures.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base text-red-600">최근 실패 로그</CardTitle>
|
||||||
|
<CardDescription>최근 발생한 DDL 실행 실패 내역입니다.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{statistics.recentFailures.map((failure, index) => (
|
||||||
|
<div key={index} className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={getDDLTypeBadgeVariant(failure.ddl_type)}>{failure.ddl_type}</Badge>
|
||||||
|
<code className="text-sm">{failure.table_name}</code>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{format(new Date(failure.executed_at), "MM-dd HH:mm", { locale: ko })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-red-600">{failure.error_message}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
/**
|
||||||
|
* DDL 실행 API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import {
|
||||||
|
CreateTableRequest,
|
||||||
|
AddColumnRequest,
|
||||||
|
DDLExecutionResult,
|
||||||
|
ValidationResult,
|
||||||
|
DDLExecutionLog,
|
||||||
|
DDLStatistics,
|
||||||
|
} from "../../types/ddl";
|
||||||
|
|
||||||
|
export const ddlApi = {
|
||||||
|
/**
|
||||||
|
* 새 테이블 생성
|
||||||
|
*/
|
||||||
|
createTable: async (request: CreateTableRequest): Promise<DDLExecutionResult> => {
|
||||||
|
const response = await apiClient.post("/ddl/tables", request);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 테이블에 컬럼 추가
|
||||||
|
*/
|
||||||
|
addColumn: async (tableName: string, request: AddColumnRequest): Promise<DDLExecutionResult> => {
|
||||||
|
const response = await apiClient.post(`/ddl/tables/${tableName}/columns`, request);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 생성 사전 검증 (실제 생성하지 않고 검증만)
|
||||||
|
*/
|
||||||
|
validateTableCreation: async (request: CreateTableRequest): Promise<ValidationResult> => {
|
||||||
|
const response = await apiClient.post("/ddl/validate/table", request);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDL 실행 로그 조회
|
||||||
|
*/
|
||||||
|
getDDLLogs: async (params?: {
|
||||||
|
limit?: number;
|
||||||
|
userId?: string;
|
||||||
|
ddlType?: string;
|
||||||
|
}): Promise<{
|
||||||
|
logs: DDLExecutionLog[];
|
||||||
|
total: number;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get("/ddl/logs", { params });
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDL 실행 통계 조회
|
||||||
|
*/
|
||||||
|
getDDLStatistics: async (params?: { fromDate?: string; toDate?: string }): Promise<DDLStatistics> => {
|
||||||
|
const response = await apiClient.get("/ddl/statistics", { params });
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 테이블의 DDL 히스토리 조회
|
||||||
|
*/
|
||||||
|
getTableDDLHistory: async (
|
||||||
|
tableName: string,
|
||||||
|
): Promise<{
|
||||||
|
tableName: string;
|
||||||
|
history: DDLExecutionLog[];
|
||||||
|
total: number;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get(`/ddl/tables/${tableName}/history`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성된 테이블 정보 조회
|
||||||
|
*/
|
||||||
|
getTableInfo: async (
|
||||||
|
tableName: string,
|
||||||
|
): Promise<{
|
||||||
|
tableInfo: any;
|
||||||
|
columns: any[];
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get(`/ddl/tables/${tableName}/info`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오래된 DDL 로그 정리
|
||||||
|
*/
|
||||||
|
cleanupOldLogs: async (
|
||||||
|
retentionDays?: number,
|
||||||
|
): Promise<{
|
||||||
|
deletedCount: number;
|
||||||
|
retentionDays: number;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.delete("/ddl/logs/cleanup", {
|
||||||
|
params: { retentionDays },
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDL 서비스 정보 조회
|
||||||
|
*/
|
||||||
|
getServiceInfo: async (): Promise<any> => {
|
||||||
|
const response = await apiClient.get("/ddl/info");
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDL 서비스 헬스체크
|
||||||
|
*/
|
||||||
|
healthCheck: async (): Promise<{
|
||||||
|
status: string;
|
||||||
|
timestamp: string;
|
||||||
|
checks: {
|
||||||
|
database: string;
|
||||||
|
service: string;
|
||||||
|
};
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get("/ddl/health");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,341 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
/** 결과 데이터 */
|
||||||
|
data?: any;
|
||||||
|
/** 오류 정보 */
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
details: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검증 결과 인터페이스
|
||||||
|
export interface ValidationResult {
|
||||||
|
/** 검증 통과 여부 */
|
||||||
|
isValid: boolean;
|
||||||
|
/** 오류 메시지 목록 */
|
||||||
|
errors: string[];
|
||||||
|
/** 경고 메시지 목록 */
|
||||||
|
warnings?: string[];
|
||||||
|
/** 검증 요약 */
|
||||||
|
summary?: 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_preview?: string;
|
||||||
|
/** 실행 성공 여부 */
|
||||||
|
success: boolean;
|
||||||
|
/** 오류 메시지 (실패 시) */
|
||||||
|
error_message?: string;
|
||||||
|
/** 실행 시간 */
|
||||||
|
executed_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DDL 통계 인터페이스
|
||||||
|
export interface DDLStatistics {
|
||||||
|
/** 전체 실행 횟수 */
|
||||||
|
totalExecutions: number;
|
||||||
|
/** 성공 횟수 */
|
||||||
|
successfulExecutions: number;
|
||||||
|
/** 실패 횟수 */
|
||||||
|
failedExecutions: number;
|
||||||
|
/** DDL 타입별 통계 */
|
||||||
|
byDDLType: Record<string, number>;
|
||||||
|
/** 사용자별 통계 */
|
||||||
|
byUser: Record<string, number>;
|
||||||
|
/** 최근 실패 로그 */
|
||||||
|
recentFailures: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 웹타입 옵션 (UI용)
|
||||||
|
export interface WebTypeOption {
|
||||||
|
value: WebType;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
supportsLength: boolean;
|
||||||
|
defaultLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 웹타입 옵션 목록
|
||||||
|
export const WEB_TYPE_OPTIONS: WebTypeOption[] = [
|
||||||
|
{
|
||||||
|
value: "text",
|
||||||
|
label: "텍스트",
|
||||||
|
description: "일반 텍스트 입력",
|
||||||
|
supportsLength: true,
|
||||||
|
defaultLength: 255,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "number",
|
||||||
|
label: "숫자",
|
||||||
|
description: "정수 입력",
|
||||||
|
supportsLength: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "decimal",
|
||||||
|
label: "소수",
|
||||||
|
description: "소수점 숫자 입력",
|
||||||
|
supportsLength: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "date",
|
||||||
|
label: "날짜",
|
||||||
|
description: "날짜 선택",
|
||||||
|
supportsLength: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "datetime",
|
||||||
|
label: "날짜시간",
|
||||||
|
description: "날짜와 시간 선택",
|
||||||
|
supportsLength: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "boolean",
|
||||||
|
label: "불린",
|
||||||
|
description: "참/거짓 값",
|
||||||
|
supportsLength: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "code",
|
||||||
|
label: "코드",
|
||||||
|
description: "공통코드 선택",
|
||||||
|
supportsLength: true,
|
||||||
|
defaultLength: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "entity",
|
||||||
|
label: "엔티티",
|
||||||
|
description: "다른 테이블 참조",
|
||||||
|
supportsLength: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "textarea",
|
||||||
|
label: "긴 텍스트",
|
||||||
|
description: "여러 줄 텍스트 입력",
|
||||||
|
supportsLength: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "select",
|
||||||
|
label: "선택박스",
|
||||||
|
description: "드롭다운 선택",
|
||||||
|
supportsLength: true,
|
||||||
|
defaultLength: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "checkbox",
|
||||||
|
label: "체크박스",
|
||||||
|
description: "체크박스 입력",
|
||||||
|
supportsLength: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "radio",
|
||||||
|
label: "라디오버튼",
|
||||||
|
description: "단일 선택 라디오버튼",
|
||||||
|
supportsLength: true,
|
||||||
|
defaultLength: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "file",
|
||||||
|
label: "파일",
|
||||||
|
description: "파일 업로드",
|
||||||
|
supportsLength: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "email",
|
||||||
|
label: "이메일",
|
||||||
|
description: "이메일 주소 입력",
|
||||||
|
supportsLength: true,
|
||||||
|
defaultLength: 255,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "tel",
|
||||||
|
label: "전화번호",
|
||||||
|
description: "전화번호 입력",
|
||||||
|
supportsLength: true,
|
||||||
|
defaultLength: 50,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 컬럼 정의 테이블 속성
|
||||||
|
export interface ColumnDefinitionTableProps {
|
||||||
|
columns: CreateColumnDefinition[];
|
||||||
|
onChange: (columns: CreateColumnDefinition[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 생성 모달 속성
|
||||||
|
export interface CreateTableModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (result: DDLExecutionResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 추가 모달 속성
|
||||||
|
export interface AddColumnModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tableName: string;
|
||||||
|
onSuccess: (result: DDLExecutionResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DDL 로그 뷰어 속성
|
||||||
|
export interface DDLLogViewerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유효성 검사 규칙
|
||||||
|
export const VALIDATION_RULES = {
|
||||||
|
tableName: {
|
||||||
|
pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
|
||||||
|
minLength: 2,
|
||||||
|
maxLength: 63,
|
||||||
|
errorMessage: "테이블명은 영문자로 시작하고, 영문자/숫자/언더스코어만 사용 가능합니다 (2-63자)",
|
||||||
|
},
|
||||||
|
columnName: {
|
||||||
|
pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
|
||||||
|
minLength: 2,
|
||||||
|
maxLength: 63,
|
||||||
|
errorMessage: "컬럼명은 영문자로 시작하고, 영문자/숫자/언더스코어만 사용 가능합니다 (2-63자)",
|
||||||
|
},
|
||||||
|
columnLength: {
|
||||||
|
min: 1,
|
||||||
|
max: 65535,
|
||||||
|
errorMessage: "컬럼 길이는 1 이상 65535 이하여야 합니다",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 시스템 테이블 목록 (보호 대상)
|
||||||
|
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;
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue