diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index ee373fa8..06920113 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -21,6 +21,7 @@ "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "mysql2": "^3.15.0", "nodemailer": "^6.9.7", "pg": "^8.16.3", "prisma": "^5.7.1", @@ -3612,6 +3613,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axios": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", @@ -4457,6 +4467,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5413,6 +5432,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -5959,6 +5987,12 @@ "node": ">=8" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -6914,6 +6948,12 @@ "node": ">= 12.0.0" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6924,6 +6964,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -7138,6 +7193,63 @@ "node": ">= 6.0.0" } }, + "node_modules/mysql2": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.0.tgz", + "integrity": "sha512-tT6pomf5Z/I7Jzxu8sScgrYBMK9bUFWd7Kbo6Fs1L0M13OOIJ/ZobGKS3Z7tQ8Re4lj+LnLXIQVZZxa3fhYKzA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8226,6 +8338,11 @@ "node": ">= 0.8" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -8431,6 +8548,15 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 258475e9..7c7e9fb8 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -39,6 +39,7 @@ "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "mysql2": "^3.15.0", "nodemailer": "^6.9.7", "pg": "^8.16.3", "prisma": "^5.7.1", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 39262f81..2d75b3d5 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -31,6 +31,7 @@ import layoutRoutes from "./routes/layoutRoutes"; import dataRoutes from "./routes/dataRoutes"; import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; +import ddlRoutes from "./routes/ddlRoutes"; import entityReferenceRoutes from "./routes/entityReferenceRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -126,6 +127,7 @@ app.use("/api/screen", screenStandardRoutes); app.use("/api/data", dataRoutes); app.use("/api/test-button-dataflow", testButtonDataflowRoutes); app.use("/api/external-db-connections", externalDbConnectionRoutes); +app.use("/api/ddl", ddlRoutes); app.use("/api/entity-reference", entityReferenceRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/controllers/ddlController.ts b/backend-node/src/controllers/ddlController.ts new file mode 100644 index 00000000..3fff2d73 --- /dev/null +++ b/backend-node/src/controllers/ddlController.ts @@ -0,0 +1,419 @@ +/** + * DDL 실행 컨트롤러 + * 테이블/컬럼 생성 API 엔드포인트 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/superAdminMiddleware"; +import { DDLExecutionService } from "../services/ddlExecutionService"; +import { DDLAuditLogger } from "../services/ddlAuditLogger"; +import { CreateTableRequest, AddColumnRequest } from "../types/ddl"; +import { logger } from "../utils/logger"; + +export class DDLController { + /** + * POST /api/ddl/tables - 새 테이블 생성 + */ + static async createTable( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName, columns, description }: CreateTableRequest = req.body; + const userId = req.user!.userId; + const userCompanyCode = req.user!.companyCode; + + // 입력값 기본 검증 + if (!tableName || !columns || columns.length === 0) { + res.status(400).json({ + success: false, + error: { + code: "INVALID_INPUT", + details: "테이블명과 최소 1개의 컬럼이 필요합니다.", + }, + }); + return; + } + + logger.info("테이블 생성 요청", { + tableName, + userId, + columnCount: columns.length, + ip: req.ip, + }); + + // inputType을 webType으로 변환 (레거시 호환성) + const processedColumns = columns.map((col) => ({ + ...col, + webType: (col.inputType || col.webType || "text") as any, + })); + + // DDL 실행 서비스 호출 + const ddlService = new DDLExecutionService(); + const result = await ddlService.createTable( + tableName, + processedColumns, + userCompanyCode, + userId, + description + ); + + if (result.success) { + res.status(200).json({ + success: true, + message: result.message, + data: { + tableName, + columnCount: columns.length, + executedQuery: result.executedQuery, + }, + }); + } else { + res.status(400).json({ + success: false, + message: result.message, + error: result.error, + }); + } + } catch (error) { + logger.error("테이블 생성 컨트롤러 오류:", { + error: (error as Error).message, + stack: (error as Error).stack, + userId: req.user?.userId, + body: req.body, + }); + + res.status(500).json({ + success: false, + error: { + code: "INTERNAL_SERVER_ERROR", + details: "테이블 생성 중 서버 오류가 발생했습니다.", + }, + }); + } + } + + /** + * POST /api/ddl/tables/:tableName/columns - 컬럼 추가 + */ + static async addColumn( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName } = req.params; + const { column }: AddColumnRequest = req.body; + const userId = req.user!.userId; + const userCompanyCode = req.user!.companyCode; + + // 입력값 기본 검증 + if (!tableName) { + res.status(400).json({ + success: false, + error: { + code: "INVALID_INPUT", + details: "테이블명이 필요합니다.", + }, + }); + return; + } + + if (!column || !column.name || (!column.inputType && !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, + }); + + // inputType을 webType으로 변환 (레거시 호환성) + const processedColumn = { + ...column, + webType: (column.inputType || column.webType || "text") as any, + }; + + // DDL 실행 서비스 호출 + const ddlService = new DDLExecutionService(); + const result = await ddlService.addColumn( + tableName, + processedColumn, + userCompanyCode, + userId + ); + + if (result.success) { + res.status(200).json({ + success: true, + message: result.message, + data: { + tableName, + columnName: column.name, + webType: column.webType, + executedQuery: result.executedQuery, + }, + }); + } else { + res.status(400).json({ + success: false, + message: result.message, + error: result.error, + }); + } + } catch (error) { + logger.error("컬럼 추가 컨트롤러 오류:", { + error: (error as Error).message, + stack: (error as Error).stack, + userId: req.user?.userId, + tableName: req.params.tableName, + body: req.body, + }); + + res.status(500).json({ + success: false, + error: { + code: "INTERNAL_SERVER_ERROR", + details: "컬럼 추가 중 서버 오류가 발생했습니다.", + }, + }); + } + } + + /** + * GET /api/ddl/logs - DDL 실행 로그 조회 + */ + static async getDDLLogs( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { limit, userId, ddlType } = req.query; + + const logs = await DDLAuditLogger.getRecentDDLLogs( + limit ? parseInt(limit as string) : 50, + userId as string, + ddlType as string + ); + + res.json({ + success: true, + data: { + logs, + total: logs.length, + }, + }); + } catch (error) { + logger.error("DDL 로그 조회 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "LOG_RETRIEVAL_FAILED", + details: "DDL 로그 조회 중 오류가 발생했습니다.", + }, + }); + } + } + + /** + * GET /api/ddl/statistics - DDL 실행 통계 조회 + */ + static async getDDLStatistics( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { fromDate, toDate } = req.query; + + const statistics = await DDLAuditLogger.getDDLStatistics( + fromDate ? new Date(fromDate as string) : undefined, + toDate ? new Date(toDate as string) : undefined + ); + + res.json({ + success: true, + data: statistics, + }); + } catch (error) { + logger.error("DDL 통계 조회 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "STATISTICS_RETRIEVAL_FAILED", + details: "DDL 통계 조회 중 오류가 발생했습니다.", + }, + }); + } + } + + /** + * GET /api/ddl/tables/:tableName/info - 생성된 테이블 정보 조회 + */ + static async getTableInfo( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName } = req.params; + + const ddlService = new DDLExecutionService(); + const tableInfo = await ddlService.getCreatedTableInfo(tableName); + + if (!tableInfo) { + res.status(404).json({ + success: false, + error: { + code: "TABLE_NOT_FOUND", + details: `테이블 '${tableName}'을 찾을 수 없습니다.`, + }, + }); + return; + } + + res.json({ + success: true, + data: tableInfo, + }); + } catch (error) { + logger.error("테이블 정보 조회 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "TABLE_INFO_RETRIEVAL_FAILED", + details: "테이블 정보 조회 중 오류가 발생했습니다.", + }, + }); + } + } + + /** + * GET /api/ddl/tables/:tableName/history - 테이블 DDL 히스토리 조회 + */ + static async getTableDDLHistory( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName } = req.params; + + const history = await DDLAuditLogger.getTableDDLHistory(tableName); + + res.json({ + success: true, + data: { + tableName, + history, + total: history.length, + }, + }); + } catch (error) { + logger.error("테이블 DDL 히스토리 조회 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "HISTORY_RETRIEVAL_FAILED", + details: "테이블 DDL 히스토리 조회 중 오류가 발생했습니다.", + }, + }); + } + } + + /** + * POST /api/ddl/validate/table - 테이블 생성 사전 검증 + */ + static async validateTableCreation( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName, columns }: CreateTableRequest = req.body; + + if (!tableName || !columns) { + res.status(400).json({ + success: false, + error: { + code: "INVALID_INPUT", + details: "테이블명과 컬럼 정보가 필요합니다.", + }, + }); + return; + } + + // 검증만 수행 (실제 생성하지 않음) + const { DDLSafetyValidator } = await import( + "../services/ddlSafetyValidator" + ); + const validationReport = DDLSafetyValidator.generateValidationReport( + tableName, + columns + ); + + res.json({ + success: true, + data: { + isValid: validationReport.validationResult.isValid, + errors: validationReport.validationResult.errors, + warnings: validationReport.validationResult.warnings, + summary: validationReport.summary, + }, + }); + } catch (error) { + logger.error("테이블 생성 검증 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "VALIDATION_ERROR", + details: "테이블 생성 검증 중 오류가 발생했습니다.", + }, + }); + } + } + + /** + * DELETE /api/ddl/logs/cleanup - 오래된 DDL 로그 정리 + */ + static async cleanupOldLogs( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { retentionDays } = req.query; + const days = retentionDays ? parseInt(retentionDays as string) : 90; + + const deletedCount = await DDLAuditLogger.cleanupOldLogs(days); + + res.json({ + success: true, + message: `${deletedCount}개의 오래된 DDL 로그가 삭제되었습니다.`, + data: { + deletedCount, + retentionDays: days, + }, + }); + } catch (error) { + logger.error("DDL 로그 정리 오류:", error); + + res.status(500).json({ + success: false, + error: { + code: "LOG_CLEANUP_FAILED", + details: "DDL 로그 정리 중 오류가 발생했습니다.", + }, + }); + } + } +} diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 47d99787..bc3e6f52 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -1,8 +1,9 @@ import { Response } from "express"; import { dynamicFormService } from "../services/dynamicFormService"; +import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService"; import { AuthenticatedRequest } from "../types/auth"; -// 폼 데이터 저장 +// 폼 데이터 저장 (기존 버전 - 레거시 지원) export const saveFormData = async ( req: AuthenticatedRequest, res: Response @@ -55,6 +56,55 @@ export const saveFormData = async ( } }; +// 개선된 폼 데이터 저장 (새 버전) +export const saveFormDataEnhanced = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { screenId, tableName, data } = req.body; + + // 필수 필드 검증 + if (screenId === undefined || screenId === null || !tableName || !data) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (screenId, tableName, data)", + }); + } + + // 메타데이터 추가 + const formDataWithMeta = { + ...data, + created_by: userId, + updated_by: userId, + screen_id: screenId, + }; + + // company_code 처리 + if (data.company_code !== undefined) { + formDataWithMeta.company_code = data.company_code; + } else if (companyCode && companyCode !== "*") { + formDataWithMeta.company_code = companyCode; + } + + // 개선된 서비스 사용 + const result = await enhancedDynamicFormService.saveFormData( + screenId, + tableName, + formDataWithMeta + ); + + res.json(result); + } catch (error: any) { + console.error("❌ 개선된 폼 데이터 저장 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "데이터 저장에 실패했습니다.", + }); + } +}; + // 폼 데이터 업데이트 export const updateFormData = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index c3192a6f..aac86625 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -443,24 +443,24 @@ export async function updateTableLabel( } /** - * 컬럼 웹 타입 설정 + * 컬럼 입력 타입 설정 */ -export async function updateColumnWebType( +export async function updateColumnInputType( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName, columnName } = req.params; - const { webType, detailSettings, inputType } = req.body; + const { inputType, detailSettings } = req.body; logger.info( - `=== 컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType} ===` + `=== 컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType} ===` ); - if (!tableName || !columnName || !webType) { + if (!tableName || !columnName || !inputType) { const response: ApiResponse = { success: false, - message: "테이블명, 컬럼명, 웹 타입이 모두 필요합니다.", + message: "테이블명, 컬럼명, 입력 타입이 모두 필요합니다.", error: { code: "MISSING_PARAMETERS", details: "필수 파라미터가 누락되었습니다.", @@ -471,33 +471,32 @@ export async function updateColumnWebType( } const tableManagementService = new TableManagementService(); - await tableManagementService.updateColumnWebType( + await tableManagementService.updateColumnInputType( tableName, columnName, - webType, - detailSettings, - inputType + inputType, + detailSettings ); logger.info( - `컬럼 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}` + `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}` ); const response: ApiResponse = { success: true, - message: "컬럼 웹 타입이 성공적으로 설정되었습니다.", + message: "컬럼 입력 타입이 성공적으로 설정되었습니다.", data: null, }; res.status(200).json(response); } catch (error) { - logger.error("컬럼 웹 타입 설정 중 오류 발생:", error); + logger.error("컬럼 입력 타입 설정 중 오류 발생:", error); const response: ApiResponse = { success: false, - message: "컬럼 웹 타입 설정 중 오류가 발생했습니다.", + message: "컬럼 입력 타입 설정 중 오류가 발생했습니다.", error: { - code: "WEB_TYPE_UPDATE_ERROR", + code: "INPUT_TYPE_UPDATE_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; @@ -735,6 +734,208 @@ export async function editTableData( } } +/** + * 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용) + */ +export async function getTableSchema( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + logger.info(`=== 테이블 스키마 정보 조회 시작: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const schema = await tableManagementService.getTableSchema(tableName); + + logger.info( + `테이블 스키마 정보 조회 완료: ${tableName}, ${schema.length}개 컬럼` + ); + + const response: ApiResponse = { + success: true, + message: "테이블 스키마 정보를 성공적으로 조회했습니다.", + data: schema, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 스키마 정보 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 스키마 정보 조회 중 오류가 발생했습니다.", + error: { + code: "TABLE_SCHEMA_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 테이블 존재 여부 확인 + */ +export async function checkTableExists( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + logger.info(`=== 테이블 존재 여부 확인 시작: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const exists = await tableManagementService.checkTableExists(tableName); + + logger.info(`테이블 존재 여부 확인 완료: ${tableName} = ${exists}`); + + const response: ApiResponse<{ exists: boolean }> = { + success: true, + message: "테이블 존재 여부를 확인했습니다.", + data: { exists }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 존재 여부 확인 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 존재 여부 확인 중 오류가 발생했습니다.", + error: { + code: "TABLE_EXISTS_CHECK_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 컬럼 웹타입 정보 조회 (화면관리 연동용) + */ +export async function getColumnWebTypes( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + logger.info(`=== 컬럼 웹타입 정보 조회 시작: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const inputTypes = + await tableManagementService.getColumnInputTypes(tableName); + + logger.info( + `컬럼 입력타입 정보 조회 완료: ${tableName}, ${inputTypes.length}개 컬럼` + ); + + const response: ApiResponse = { + success: true, + message: "컬럼 입력타입 정보를 성공적으로 조회했습니다.", + data: inputTypes, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("컬럼 웹타입 정보 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "컬럼 웹타입 정보 조회 중 오류가 발생했습니다.", + error: { + code: "COLUMN_WEB_TYPES_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 데이터베이스 연결 상태 확인 + */ +export async function checkDatabaseConnection( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 데이터베이스 연결 상태 확인 시작 ==="); + + const tableManagementService = new TableManagementService(); + const connectionStatus = + await tableManagementService.checkDatabaseConnection(); + + logger.info( + `데이터베이스 연결 상태: ${connectionStatus.connected ? "연결됨" : "연결 안됨"}` + ); + + const response: ApiResponse<{ connected: boolean; message: string }> = { + success: true, + message: "데이터베이스 연결 상태를 확인했습니다.", + data: connectionStatus, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("데이터베이스 연결 상태 확인 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "데이터베이스 연결 상태 확인 중 오류가 발생했습니다.", + error: { + code: "DATABASE_CONNECTION_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + /** * 테이블 데이터 삭제 */ @@ -809,3 +1010,41 @@ export async function deleteTableData( res.status(500).json(response); } } + +/** + * 컬럼 웹 타입 설정 (레거시 지원) + * @deprecated updateColumnInputType 사용 권장 + */ +export async function updateColumnWebType( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { webType, detailSettings, inputType } = req.body; + + logger.warn( + `레거시 API 사용: updateColumnWebType → updateColumnInputType 사용 권장` + ); + + // webType을 inputType으로 변환 + const convertedInputType = inputType || webType || "text"; + + // 새로운 메서드 호출 + req.body = { inputType: convertedInputType, detailSettings }; + await updateColumnInputType(req, res); + } catch (error) { + logger.error("레거시 컬럼 웹 타입 설정 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "컬럼 웹 타입 설정 중 오류가 발생했습니다.", + error: { + code: "WEB_TYPE_UPDATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/database/DatabaseConnectorFactory.ts b/backend-node/src/database/DatabaseConnectorFactory.ts new file mode 100644 index 00000000..6bb55d88 --- /dev/null +++ b/backend-node/src/database/DatabaseConnectorFactory.ts @@ -0,0 +1,52 @@ +import { DatabaseConnector, ConnectionConfig } from '../interfaces/DatabaseConnector'; +import { PostgreSQLConnector } from './PostgreSQLConnector'; + +export class DatabaseConnectorFactory { + private static connectors = new Map(); + + static async createConnector( + type: string, + config: ConnectionConfig, + connectionId: number // Added connectionId for unique key + ): Promise { + const key = `${type}-${connectionId}`; // Use connectionId for unique key + if (this.connectors.has(key)) { + return this.connectors.get(key)!; + } + + let connector: DatabaseConnector; + + switch (type.toLowerCase()) { + case 'postgresql': + connector = new PostgreSQLConnector(config); + break; + // Add other database types here + default: + throw new Error(`지원하지 않는 데이터베이스 타입: ${type}`); + } + + this.connectors.set(key, connector); + return connector; + } + + static async getConnector(connectionId: number, type: string): Promise { + const key = `${type}-${connectionId}`; + return this.connectors.get(key); + } + + static async closeConnector(connectionId: number, type: string): Promise { + const key = `${type}-${connectionId}`; + const connector = this.connectors.get(key); + if (connector) { + await connector.disconnect(); + this.connectors.delete(key); + } + } + + static async closeAll(): Promise { + for (const connector of this.connectors.values()) { + await connector.disconnect(); + } + this.connectors.clear(); + } +} \ No newline at end of file diff --git a/backend-node/src/database/MySQLConnector.ts b/backend-node/src/database/MySQLConnector.ts new file mode 100644 index 00000000..6d27bad6 --- /dev/null +++ b/backend-node/src/database/MySQLConnector.ts @@ -0,0 +1,175 @@ +import * as mysql from 'mysql2/promise'; +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export class MySQLConnector implements DatabaseConnector { + private connection: mysql.Connection | null = null; + private config: ConnectionConfig; + + constructor(config: ConnectionConfig) { + this.config = config; + } + + async connect(): Promise { + if (this.connection) { + throw new Error('이미 연결되어 있습니다.'); + } + + this.connection = await mysql.createConnection({ + host: this.config.host, + port: this.config.port, + database: this.config.database, + user: this.config.username, + password: this.config.password, + connectTimeout: this.config.connectionTimeout || 30000, + ssl: this.config.sslEnabled ? { rejectUnauthorized: false } : undefined, + }); + } + + async disconnect(): Promise { + if (this.connection) { + await this.connection.end(); + this.connection = null; + } + } + + async testConnection(): Promise { + const startTime = Date.now(); + try { + await this.connect(); + + const [versionResult] = await this.connection!.query('SELECT VERSION() as version'); + const [sizeResult] = await this.connection!.query( + 'SELECT SUM(data_length + index_length) as size FROM information_schema.tables WHERE table_schema = DATABASE()' + ); + + const responseTime = Date.now() - startTime; + + return { + success: true, + message: 'MySQL 연결이 성공했습니다.', + details: { + response_time: responseTime, + server_version: (versionResult as any)[0]?.version || '알 수 없음', + database_size: this.formatBytes(parseInt((sizeResult as any)[0]?.size || '0')), + }, + }; + } catch (error) { + return { + success: false, + message: 'MySQL 연결에 실패했습니다.', + error: { + code: 'CONNECTION_FAILED', + details: error instanceof Error ? error.message : '알 수 없는 오류', + }, + }; + } finally { + await this.disconnect(); + } + } + + async executeQuery(query: string): Promise { + if (!this.connection) { + await this.connect(); + } + + try { + const [rows, fields] = await this.connection!.query(query); + return { + rows: rows as any[], + rowCount: Array.isArray(rows) ? rows.length : 0, + fields: (fields as mysql.FieldPacket[]).map(field => ({ + name: field.name, + dataType: field.type.toString(), + })), + }; + } finally { + await this.disconnect(); + } + } + + async getTables(): Promise { + if (!this.connection) { + await this.connect(); + } + + try { + const [tables] = await this.connection!.query(` + SELECT + t.TABLE_NAME as table_name, + t.TABLE_COMMENT as table_description + FROM information_schema.TABLES t + WHERE t.TABLE_SCHEMA = DATABASE() + ORDER BY t.TABLE_NAME + `); + + const result: TableInfo[] = []; + + for (const table of tables as any[]) { + const [columns] = await this.connection!.query(` + SELECT + COLUMN_NAME as column_name, + DATA_TYPE as data_type, + IS_NULLABLE as is_nullable, + COLUMN_DEFAULT as column_default + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + `, [table.table_name]); + + result.push({ + table_name: table.table_name, + description: table.table_description || null, + columns: columns as any[], + }); + } + + return result; + } finally { + await this.disconnect(); + } + } + + async getColumns(tableName: string): Promise> { + if (!this.connection) { + await this.connect(); + } + + try { + const [columns] = await this.connection!.query(` + SELECT + COLUMN_NAME as name, + DATA_TYPE as dataType, + IS_NULLABLE = 'YES' as isNullable, + COLUMN_DEFAULT as defaultValue + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + `, [tableName]); + + return (columns as any[]).map(col => ({ + name: col.name, + dataType: col.dataType, + isNullable: col.isNullable, + defaultValue: col.defaultValue, + })); + } finally { + await this.disconnect(); + } + } + + private formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} diff --git a/backend-node/src/database/PostgreSQLConnector.ts b/backend-node/src/database/PostgreSQLConnector.ts new file mode 100644 index 00000000..1a6065a4 --- /dev/null +++ b/backend-node/src/database/PostgreSQLConnector.ts @@ -0,0 +1,145 @@ +import { Client } from 'pg'; +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export class PostgreSQLConnector implements DatabaseConnector { + private client: Client | null = null; + private config: ConnectionConfig; + + constructor(config: ConnectionConfig) { + this.config = config; + } + + async connect(): Promise { + if (this.client) { + await this.disconnect(); + } + const clientConfig: any = { + host: this.config.host, + port: this.config.port, + database: this.config.database, + user: this.config.user, + password: this.config.password, + }; + + if (this.config.connectionTimeoutMillis != null) { + clientConfig.connectionTimeoutMillis = this.config.connectionTimeoutMillis; + } + + if (this.config.queryTimeoutMillis != null) { + clientConfig.query_timeout = this.config.queryTimeoutMillis; + } + + if (this.config.ssl != null) { + clientConfig.ssl = this.config.ssl; + } + + this.client = new Client(clientConfig); + await this.client.connect(); + } + + async disconnect(): Promise { + if (this.client) { + await this.client.end(); + this.client = null; + } + } + + async testConnection(): Promise { + const startTime = Date.now(); + try { + await this.connect(); + const result = await this.client!.query("SELECT version(), pg_database_size(current_database()) as size"); + const responseTime = Date.now() - startTime; + await this.disconnect(); + return { + success: true, + message: "PostgreSQL 연결이 성공했습니다.", + details: { + response_time: responseTime, + server_version: result.rows[0]?.version || "알 수 없음", + database_size: this.formatBytes(parseInt(result.rows[0]?.size || "0")), + }, + }; + } catch (error: any) { + await this.disconnect(); + return { + success: false, + message: "PostgreSQL 연결에 실패했습니다.", + error: { + code: "CONNECTION_FAILED", + details: error.message || "알 수 없는 오류", + }, + }; + } + } + + async executeQuery(query: string): Promise { + try { + await this.connect(); + const result = await this.client!.query(query); + await this.disconnect(); + return { + rows: result.rows, + rowCount: result.rowCount ?? undefined, + fields: result.fields ?? undefined, + }; + } catch (error: any) { + await this.disconnect(); + throw new Error(`PostgreSQL 쿼리 실행 실패: ${error.message}`); + } + } + + async getTables(): Promise { + try { + await this.connect(); + const result = await this.client!.query(` + SELECT + t.table_name, + obj_description(quote_ident(t.table_name)::regclass::oid, 'pg_class') as table_description + FROM information_schema.tables t + WHERE t.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + ORDER BY t.table_name; + `); + await this.disconnect(); + return result.rows.map((row) => ({ + table_name: row.table_name, + description: row.table_description, + columns: [], // Columns will be fetched by getColumns + })); + } catch (error: any) { + await this.disconnect(); + throw new Error(`PostgreSQL 테이블 목록 조회 실패: ${error.message}`); + } + } + + async getColumns(tableName: string): Promise { + try { + await this.connect(); + const result = await this.client!.query(` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position; + `, [tableName]); + await this.disconnect(); + return result.rows; + } catch (error: any) { + await this.disconnect(); + throw new Error(`PostgreSQL 컬럼 정보 조회 실패: ${error.message}`); + } + } + + private formatBytes(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + } +} \ No newline at end of file diff --git a/backend-node/src/interfaces/DatabaseConnector.ts b/backend-node/src/interfaces/DatabaseConnector.ts new file mode 100644 index 00000000..c8980eef --- /dev/null +++ b/backend-node/src/interfaces/DatabaseConnector.ts @@ -0,0 +1,27 @@ +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export interface ConnectionConfig { + host: string; + port: number; + database: string; + user: string; + password: string; + connectionTimeoutMillis?: number; + queryTimeoutMillis?: number; + ssl?: boolean | { rejectUnauthorized: boolean }; +} + +export interface QueryResult { + rows: any[]; + rowCount?: number; + fields?: any[]; +} + +export interface DatabaseConnector { + connect(): Promise; + disconnect(): Promise; + testConnection(): Promise; + executeQuery(query: string): Promise; + getTables(): Promise; + getColumns(tableName: string): Promise; // 특정 테이블의 컬럼 정보 조회 +} \ No newline at end of file diff --git a/backend-node/src/middleware/superAdminMiddleware.ts b/backend-node/src/middleware/superAdminMiddleware.ts new file mode 100644 index 00000000..37b3f24a --- /dev/null +++ b/backend-node/src/middleware/superAdminMiddleware.ts @@ -0,0 +1,200 @@ +/** + * 슈퍼관리자 권한 검증 미들웨어 + * 회사코드가 '*'인 최고 관리자만 DDL 실행을 허용 + */ + +import { Request, Response, NextFunction } from "express"; +import { logger } from "../utils/logger"; + +// DDL 요청 시간 추적을 위한 메모리 저장소 +const ddlRequestTimes = new Map(); + +// AuthenticatedRequest 타입 확장 +export interface AuthenticatedRequest extends Request { + user?: { + userId: string; + userName: string; + companyCode: string; + userLang?: string; + }; +} + +/** + * 슈퍼관리자 권한 확인 미들웨어 + * 회사코드가 '*'이고 userId가 'plm_admin'인 사용자만 허용 + */ +export const requireSuperAdmin = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + // 인증 여부 확인 + if (!req.user) { + logger.warn("DDL 실행 시도 - 인증되지 않은 사용자", { + ip: req.ip, + userAgent: req.get("User-Agent"), + url: req.originalUrl, + }); + + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + // 슈퍼관리자 권한 확인 (회사코드가 '*'이고 plm_admin 사용자) + if (req.user.companyCode !== "*" || req.user.userId !== "plm_admin") { + logger.warn("DDL 실행 시도 - 권한 부족", { + userId: req.user.userId, + companyCode: req.user.companyCode, + ip: req.ip, + userAgent: req.get("User-Agent"), + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "SUPER_ADMIN_REQUIRED", + details: + "최고 관리자 권한이 필요합니다. DDL 실행은 회사코드가 '*'인 plm_admin 사용자만 가능합니다.", + }, + }); + return; + } + + // 권한 확인 로깅 + logger.info("DDL 실행 권한 확인 완료", { + userId: req.user.userId, + companyCode: req.user.companyCode, + ip: req.ip, + url: req.originalUrl, + }); + + next(); + } catch (error) { + logger.error("슈퍼관리자 권한 확인 중 오류 발생:", error); + + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * DDL 실행 전 추가 보안 검증 + * 세션 유효성 및 사용자 상태 재확인 + */ +export const validateDDLPermission = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + const user = req.user!; // requireSuperAdmin을 통과했으므로 user 존재 보장 + + // 세션 유효성 재확인 + if (!user.userId || !user.companyCode) { + logger.error("DDL 실행 - 세션 데이터 불완전", { + userId: user.userId, + companyCode: user.companyCode, + }); + + res.status(401).json({ + success: false, + error: { + code: "INVALID_SESSION", + details: "세션 정보가 불완전합니다. 다시 로그인해주세요.", + }, + }); + return; + } + + // 추가 보안 체크 - 메모리 기반 요청 시간 간격 제한 + const now = Date.now(); + const minInterval = 5000; // 5초 간격 제한 + const lastDDLTime = ddlRequestTimes.get(user.userId); + + if (lastDDLTime && now - lastDDLTime < minInterval) { + logger.warn("DDL 실행 - 너무 빈번한 요청", { + userId: user.userId, + timeSinceLastDDL: now - lastDDLTime, + }); + + res.status(429).json({ + success: false, + error: { + code: "TOO_MANY_REQUESTS", + details: + "DDL 실행 요청이 너무 빈번합니다. 잠시 후 다시 시도해주세요.", + }, + }); + return; + } + + // 마지막 DDL 실행 시간 기록 + ddlRequestTimes.set(user.userId, now); + + logger.info("DDL 실행 추가 보안 검증 완료", { + userId: user.userId, + companyCode: user.companyCode, + }); + + next(); + } catch (error) { + logger.error("DDL 권한 추가 검증 중 오류 발생:", error); + + res.status(500).json({ + success: false, + error: { + code: "VALIDATION_ERROR", + details: "권한 검증 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 사용자가 슈퍼관리자인지 확인하는 유틸리티 함수 + */ +export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => { + return user?.companyCode === "*" && user?.userId === "plm_admin"; +}; + +/** + * DDL 실행 권한 체크 (미들웨어 없이 사용) + */ +export const checkDDLPermission = ( + user: AuthenticatedRequest["user"] +): { + hasPermission: boolean; + errorCode?: string; + errorMessage?: string; +} => { + if (!user) { + return { + hasPermission: false, + errorCode: "AUTHENTICATION_REQUIRED", + errorMessage: "인증이 필요합니다.", + }; + } + + if (!isSuperAdmin(user)) { + return { + hasPermission: false, + errorCode: "SUPER_ADMIN_REQUIRED", + errorMessage: "최고 관리자 권한이 필요합니다.", + }; + } + + return { hasPermission: true }; +}; diff --git a/backend-node/src/routes/ddlRoutes.ts b/backend-node/src/routes/ddlRoutes.ts new file mode 100644 index 00000000..f32ae586 --- /dev/null +++ b/backend-node/src/routes/ddlRoutes.ts @@ -0,0 +1,215 @@ +/** + * DDL 실행 관련 라우터 + * 테이블/컬럼 생성 API 라우팅 + */ + +import express from "express"; +import { DDLController } from "../controllers/ddlController"; +import { + requireSuperAdmin, + validateDDLPermission, +} from "../middleware/superAdminMiddleware"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// ============================================ +// DDL 실행 라우터 (최고 관리자 전용) +// ============================================ + +/** + * 테이블 생성 + * POST /api/ddl/tables + */ +router.post( + "/tables", + authenticateToken, // 기본 인증 + requireSuperAdmin, // 슈퍼관리자 권한 확인 + validateDDLPermission, // DDL 실행 추가 검증 + DDLController.createTable +); + +/** + * 컬럼 추가 + * POST /api/ddl/tables/:tableName/columns + */ +router.post( + "/tables/:tableName/columns", + authenticateToken, + requireSuperAdmin, + validateDDLPermission, + DDLController.addColumn +); + +/** + * 테이블 생성 사전 검증 (실제 생성하지 않고 검증만) + * POST /api/ddl/validate/table + */ +router.post( + "/validate/table", + authenticateToken, + requireSuperAdmin, + DDLController.validateTableCreation +); + +// ============================================ +// DDL 로그 및 모니터링 라우터 +// ============================================ + +/** + * DDL 실행 로그 조회 + * GET /api/ddl/logs + */ +router.get( + "/logs", + authenticateToken, + requireSuperAdmin, + DDLController.getDDLLogs +); + +/** + * DDL 실행 통계 조회 + * GET /api/ddl/statistics + */ +router.get( + "/statistics", + authenticateToken, + requireSuperAdmin, + DDLController.getDDLStatistics +); + +/** + * 특정 테이블의 DDL 히스토리 조회 + * GET /api/ddl/tables/:tableName/history + */ +router.get( + "/tables/:tableName/history", + authenticateToken, + requireSuperAdmin, + DDLController.getTableDDLHistory +); + +/** + * 생성된 테이블 정보 조회 + * GET /api/ddl/tables/:tableName/info + */ +router.get( + "/tables/:tableName/info", + authenticateToken, + requireSuperAdmin, + DDLController.getTableInfo +); + +// ============================================ +// DDL 시스템 관리 라우터 +// ============================================ + +/** + * 오래된 DDL 로그 정리 + * DELETE /api/ddl/logs/cleanup + */ +router.delete( + "/logs/cleanup", + authenticateToken, + requireSuperAdmin, + DDLController.cleanupOldLogs +); + +// ============================================ +// 라우터 정보 및 헬스체크 +// ============================================ + +/** + * DDL 라우터 정보 조회 + * GET /api/ddl/info + */ +router.get("/info", authenticateToken, requireSuperAdmin, (req, res) => { + res.json({ + success: true, + data: { + service: "DDL Execution Service", + version: "1.0.0", + description: "PostgreSQL 테이블 및 컬럼 동적 생성 서비스", + endpoints: { + tables: { + create: "POST /api/ddl/tables", + addColumn: "POST /api/ddl/tables/:tableName/columns", + getInfo: "GET /api/ddl/tables/:tableName/info", + getHistory: "GET /api/ddl/tables/:tableName/history", + }, + validation: { + validateTable: "POST /api/ddl/validate/table", + }, + monitoring: { + logs: "GET /api/ddl/logs", + statistics: "GET /api/ddl/statistics", + cleanup: "DELETE /api/ddl/logs/cleanup", + }, + }, + requirements: { + authentication: "Bearer Token 필요", + authorization: "회사코드 '*'인 plm_admin 사용자만 접근 가능", + safety: "모든 DDL 실행은 안전성 검증 후 수행", + logging: "모든 DDL 실행은 감사 로그에 기록", + }, + supportedWebTypes: [ + "text", + "number", + "decimal", + "date", + "datetime", + "boolean", + "code", + "entity", + "textarea", + "select", + "checkbox", + "radio", + "file", + "email", + "tel", + ], + }, + }); +}); + +/** + * DDL 서비스 헬스체크 + * GET /api/ddl/health + */ +router.get("/health", authenticateToken, async (req, res) => { + try { + // 기본적인 데이터베이스 연결 테스트 + const { PrismaClient } = await import("@prisma/client"); + const prisma = new PrismaClient(); + + await prisma.$queryRaw`SELECT 1`; + await prisma.$disconnect(); + + res.json({ + success: true, + status: "healthy", + timestamp: new Date().toISOString(), + checks: { + database: "connected", + service: "operational", + }, + }); + } catch (error) { + res.status(503).json({ + success: false, + status: "unhealthy", + timestamp: new Date().toISOString(), + error: { + code: "HEALTH_CHECK_FAILED", + details: "DDL 서비스 상태 확인 실패", + }, + checks: { + database: "disconnected", + service: "error", + }, + }); + } +}); + +export default router; diff --git a/backend-node/src/routes/dynamicFormRoutes.ts b/backend-node/src/routes/dynamicFormRoutes.ts index 01d2e264..5514fb54 100644 --- a/backend-node/src/routes/dynamicFormRoutes.ts +++ b/backend-node/src/routes/dynamicFormRoutes.ts @@ -2,6 +2,7 @@ import express from "express"; import { authenticateToken } from "../middleware/authMiddleware"; import { saveFormData, + saveFormDataEnhanced, updateFormData, updateFormDataPartial, deleteFormData, @@ -18,7 +19,8 @@ const router = express.Router(); router.use(authenticateToken); // 폼 데이터 CRUD -router.post("/save", saveFormData); +router.post("/save", saveFormData); // 기존 버전 (레거시 지원) +router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전 router.put("/:id", updateFormData); router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트 router.delete("/:id", deleteFormData); diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index ee5800aa..c0b35b94 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -8,11 +8,16 @@ import { getTableLabels, getColumnLabels, updateColumnWebType, + updateColumnInputType, updateTableLabel, getTableData, addTableData, editTableData, deleteTableData, + getTableSchema, + checkTableExists, + getColumnWebTypes, + checkDatabaseConnection, } from "../controllers/tableManagementController"; const router = express.Router(); @@ -66,7 +71,7 @@ router.get("/tables/:tableName/labels", getTableLabels); router.get("/tables/:tableName/columns/:columnName/labels", getColumnLabels); /** - * 컬럼 웹 타입 설정 + * 컬럼 웹 타입 설정 (레거시 지원) * PUT /api/table-management/tables/:tableName/columns/:columnName/web-type */ router.put( @@ -74,6 +79,51 @@ router.put( updateColumnWebType ); +/** + * 컬럼 입력 타입 설정 (새로운 시스템) + * PUT /api/table-management/tables/:tableName/columns/:columnName/input-type + */ +router.put( + "/tables/:tableName/columns/:columnName/input-type", + updateColumnInputType +); + +/** + * 개별 컬럼 설정 업데이트 (PUT 방식) + * PUT /api/table-management/tables/:tableName/columns/:columnName + */ +router.put("/tables/:tableName/columns/:columnName", updateColumnSettings); + +/** + * 여러 컬럼 설정 일괄 업데이트 + * PUT /api/table-management/tables/:tableName/columns/batch + */ +router.put("/tables/:tableName/columns/batch", updateAllColumnSettings); + +/** + * 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용) + * GET /api/table-management/tables/:tableName/schema + */ +router.get("/tables/:tableName/schema", getTableSchema); + +/** + * 테이블 존재 여부 확인 + * GET /api/table-management/tables/:tableName/exists + */ +router.get("/tables/:tableName/exists", checkTableExists); + +/** + * 컬럼 웹타입 정보 조회 (화면관리 연동용) + * GET /api/table-management/tables/:tableName/web-types + */ +router.get("/tables/:tableName/web-types", getColumnWebTypes); + +/** + * 데이터베이스 연결 상태 확인 + * GET /api/table-management/health + */ +router.get("/health", checkDatabaseConnection); + /** * 테이블 데이터 조회 (페이징 + 검색) * POST /api/table-management/tables/:tableName/data diff --git a/backend-node/src/services/dbConnectionManager.ts b/backend-node/src/services/dbConnectionManager.ts new file mode 100644 index 00000000..78500c48 --- /dev/null +++ b/backend-node/src/services/dbConnectionManager.ts @@ -0,0 +1,59 @@ +import { DatabaseConnectorFactory } from '../database/DatabaseConnectorFactory'; +import { ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export class DbConnectionManager { + static async testConnection( + connectionId: number, + dbType: string, + config: ConnectionConfig + ): Promise { + const connector = await DatabaseConnectorFactory.createConnector(dbType, config, connectionId); + try { + return await connector.testConnection(); + } finally { + await DatabaseConnectorFactory.closeConnector(connectionId, dbType); // Close after test + } + } + + static async executeQuery( + connectionId: number, + dbType: string, + config: ConnectionConfig, + query: string + ): Promise { + const connector = await DatabaseConnectorFactory.createConnector(dbType, config, connectionId); + try { + return await connector.executeQuery(query); + } finally { + await DatabaseConnectorFactory.closeConnector(connectionId, dbType); + } + } + + static async getTables( + connectionId: number, + dbType: string, + config: ConnectionConfig + ): Promise { + const connector = await DatabaseConnectorFactory.createConnector(dbType, config, connectionId); + try { + return await connector.getTables(); + } finally { + await DatabaseConnectorFactory.closeConnector(connectionId, dbType); + } + } + + static async getColumns( + connectionId: number, + dbType: string, + config: ConnectionConfig, + tableName: string + ): Promise { + const connector = await DatabaseConnectorFactory.createConnector(dbType, config, connectionId); + try { + return await connector.getColumns(tableName); + } finally { + await DatabaseConnectorFactory.closeConnector(connectionId, dbType); + } + } +} \ No newline at end of file diff --git a/backend-node/src/services/ddlAuditLogger.ts b/backend-node/src/services/ddlAuditLogger.ts new file mode 100644 index 00000000..988e688f --- /dev/null +++ b/backend-node/src/services/ddlAuditLogger.ts @@ -0,0 +1,368 @@ +/** + * DDL 실행 감사 로깅 서비스 + * 모든 DDL 실행을 추적하고 기록 + */ + +import { PrismaClient } from "@prisma/client"; +import { logger } from "../utils/logger"; + +const prisma = new PrismaClient(); + +export class DDLAuditLogger { + /** + * DDL 실행 로그 기록 + */ + static async logDDLExecution( + userId: string, + companyCode: string, + ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN", + tableName: string, + ddlQuery: string, + success: boolean, + error?: string, + additionalInfo?: Record + ): Promise { + try { + // DDL 실행 로그 데이터베이스에 저장 + const logEntry = await prisma.$executeRaw` + INSERT INTO ddl_execution_log ( + user_id, + company_code, + ddl_type, + table_name, + ddl_query, + success, + error_message, + executed_at + ) VALUES ( + ${userId}, + ${companyCode}, + ${ddlType}, + ${tableName}, + ${ddlQuery}, + ${success}, + ${error || null}, + NOW() + ) + `; + + // 추가 로깅 (파일 로그) + const logData = { + userId, + companyCode, + ddlType, + tableName, + success, + queryLength: ddlQuery.length, + error: error || null, + additionalInfo: additionalInfo || null, + timestamp: new Date().toISOString(), + }; + + if (success) { + logger.info("DDL 실행 성공", logData); + } else { + logger.error("DDL 실행 실패", { ...logData, ddlQuery }); + } + + // 중요한 DDL 실행은 별도 알림 (필요시) + if (ddlType === "CREATE_TABLE" || ddlType === "DROP_TABLE") { + logger.warn("중요 DDL 실행", { + ...logData, + severity: "HIGH", + action: "TABLE_STRUCTURE_CHANGE", + }); + } + } catch (logError) { + // 로그 기록 실패는 시스템에 영향을 주지 않도록 처리 + logger.error("DDL 실행 로그 기록 실패:", { + originalUserId: userId, + originalDdlType: ddlType, + originalTableName: tableName, + originalSuccess: success, + logError: logError, + }); + + // 로그 기록 실패를 파일 로그로라도 남김 + console.error("CRITICAL: DDL 로그 기록 실패", { + userId, + ddlType, + tableName, + success, + logError, + timestamp: new Date().toISOString(), + }); + } + } + + /** + * DDL 실행 시작 로그 + */ + static async logDDLStart( + userId: string, + companyCode: string, + ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN", + tableName: string, + requestData: any + ): Promise { + logger.info("DDL 실행 시작", { + userId, + companyCode, + ddlType, + tableName, + requestData, + timestamp: new Date().toISOString(), + }); + } + + /** + * 최근 DDL 실행 로그 조회 + */ + static async getRecentDDLLogs( + limit: number = 50, + userId?: string, + ddlType?: string + ): Promise { + try { + let whereClause = "WHERE 1=1"; + const params: any[] = []; + + if (userId) { + whereClause += " AND user_id = $" + (params.length + 1); + params.push(userId); + } + + if (ddlType) { + whereClause += " AND ddl_type = $" + (params.length + 1); + params.push(ddlType); + } + + const query = ` + SELECT + id, + user_id, + company_code, + ddl_type, + table_name, + success, + error_message, + executed_at, + CASE + WHEN LENGTH(ddl_query) > 100 THEN SUBSTRING(ddl_query, 1, 100) || '...' + ELSE ddl_query + END as ddl_query_preview + FROM ddl_execution_log + ${whereClause} + ORDER BY executed_at DESC + LIMIT $${params.length + 1} + `; + + params.push(limit); + + const logs = await prisma.$queryRawUnsafe(query, ...params); + return logs as any[]; + } catch (error) { + logger.error("DDL 로그 조회 실패:", error); + return []; + } + } + + /** + * DDL 실행 통계 조회 + */ + static async getDDLStatistics( + fromDate?: Date, + toDate?: Date + ): Promise<{ + totalExecutions: number; + successfulExecutions: number; + failedExecutions: number; + byDDLType: Record; + byUser: Record; + recentFailures: any[]; + }> { + try { + let dateFilter = ""; + const params: any[] = []; + + if (fromDate) { + dateFilter += " AND executed_at >= $" + (params.length + 1); + params.push(fromDate); + } + + if (toDate) { + dateFilter += " AND executed_at <= $" + (params.length + 1); + params.push(toDate); + } + + // 전체 통계 + const totalStats = (await prisma.$queryRawUnsafe( + ` + SELECT + COUNT(*) as total_executions, + SUM(CASE WHEN success = true THEN 1 ELSE 0 END) as successful_executions, + SUM(CASE WHEN success = false THEN 1 ELSE 0 END) as failed_executions + FROM ddl_execution_log + WHERE 1=1 ${dateFilter} + `, + ...params + )) as any[]; + + // DDL 타입별 통계 + const ddlTypeStats = (await prisma.$queryRawUnsafe( + ` + SELECT ddl_type, COUNT(*) as count + FROM ddl_execution_log + WHERE 1=1 ${dateFilter} + GROUP BY ddl_type + ORDER BY count DESC + `, + ...params + )) as any[]; + + // 사용자별 통계 + const userStats = (await prisma.$queryRawUnsafe( + ` + SELECT user_id, COUNT(*) as count + FROM ddl_execution_log + WHERE 1=1 ${dateFilter} + GROUP BY user_id + ORDER BY count DESC + LIMIT 10 + `, + ...params + )) as any[]; + + // 최근 실패 로그 + const recentFailures = (await prisma.$queryRawUnsafe( + ` + SELECT + user_id, + ddl_type, + table_name, + error_message, + executed_at + FROM ddl_execution_log + WHERE success = false ${dateFilter} + ORDER BY executed_at DESC + LIMIT 10 + `, + ...params + )) as any[]; + + const stats = totalStats[0]; + + return { + totalExecutions: parseInt(stats.total_executions) || 0, + successfulExecutions: parseInt(stats.successful_executions) || 0, + failedExecutions: parseInt(stats.failed_executions) || 0, + byDDLType: ddlTypeStats.reduce((acc, row) => { + acc[row.ddl_type] = parseInt(row.count); + return acc; + }, {}), + byUser: userStats.reduce((acc, row) => { + acc[row.user_id] = parseInt(row.count); + return acc; + }, {}), + recentFailures, + }; + } catch (error) { + logger.error("DDL 통계 조회 실패:", error); + return { + totalExecutions: 0, + successfulExecutions: 0, + failedExecutions: 0, + byDDLType: {}, + byUser: {}, + recentFailures: [], + }; + } + } + + /** + * 특정 테이블의 DDL 히스토리 조회 + */ + static async getTableDDLHistory(tableName: string): Promise { + try { + const history = await prisma.$queryRawUnsafe( + ` + SELECT + id, + user_id, + ddl_type, + ddl_query, + success, + error_message, + executed_at + FROM ddl_execution_log + WHERE table_name = $1 + ORDER BY executed_at DESC + LIMIT 20 + `, + tableName + ); + + return history as any[]; + } catch (error) { + logger.error(`테이블 '${tableName}' DDL 히스토리 조회 실패:`, error); + return []; + } + } + + /** + * DDL 로그 정리 (오래된 로그 삭제) + */ + static async cleanupOldLogs(retentionDays: number = 90): Promise { + try { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + + const result = await prisma.$executeRaw` + DELETE FROM ddl_execution_log + WHERE executed_at < ${cutoffDate} + `; + + logger.info(`DDL 로그 정리 완료: ${result}개 레코드 삭제`, { + retentionDays, + cutoffDate: cutoffDate.toISOString(), + }); + + return result as number; + } catch (error) { + logger.error("DDL 로그 정리 실패:", error); + return 0; + } + } + + /** + * 긴급 상황 로그 (시스템 테이블 접근 시도 등) + */ + static async logSecurityAlert( + userId: string, + companyCode: string, + alertType: + | "SYSTEM_TABLE_ACCESS" + | "INVALID_PERMISSION" + | "SUSPICIOUS_ACTIVITY", + details: string, + requestData?: any + ): Promise { + try { + // 보안 알림은 별도의 고급 로깅 + logger.error("DDL 보안 알림", { + alertType, + userId, + companyCode, + details, + requestData, + severity: "CRITICAL", + timestamp: new Date().toISOString(), + }); + + // 필요시 외부 알림 시스템 연동 (이메일, 슬랙 등) + // await sendSecurityAlert(alertType, userId, details); + } catch (error) { + logger.error("보안 알림 기록 실패:", error); + } + } +} diff --git a/backend-node/src/services/ddlExecutionService.ts b/backend-node/src/services/ddlExecutionService.ts new file mode 100644 index 00000000..9167a48e --- /dev/null +++ b/backend-node/src/services/ddlExecutionService.ts @@ -0,0 +1,785 @@ +/** + * DDL 실행 서비스 + * 실제 PostgreSQL 테이블 및 컬럼 생성을 담당 + */ + +import { PrismaClient } from "@prisma/client"; +import { + CreateColumnDefinition, + DDLExecutionResult, + WEB_TYPE_TO_POSTGRES_MAP, + WebType, +} from "../types/ddl"; +import { DDLSafetyValidator } from "./ddlSafetyValidator"; +import { DDLAuditLogger } from "./ddlAuditLogger"; +import { logger } from "../utils/logger"; +import { cache, CacheKeys } from "../utils/cache"; + +const prisma = new PrismaClient(); + +export class DDLExecutionService { + /** + * 새 테이블 생성 + */ + async createTable( + tableName: string, + columns: CreateColumnDefinition[], + userCompanyCode: string, + userId: string, + description?: string + ): Promise { + // DDL 실행 시작 로그 + await DDLAuditLogger.logDDLStart( + userId, + userCompanyCode, + "CREATE_TABLE", + tableName, + { columns, description } + ); + + try { + // 1. 권한 검증 + this.validateSuperAdminPermission(userCompanyCode); + + // 2. 안전성 검증 + const validation = DDLSafetyValidator.validateTableCreation( + tableName, + columns + ); + if (!validation.isValid) { + const errorMessage = `테이블 생성 검증 실패: ${validation.errors.join(", ")}`; + + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "CREATE_TABLE", + tableName, + "VALIDATION_FAILED", + false, + errorMessage + ); + + return { + success: false, + message: errorMessage, + error: { + code: "VALIDATION_FAILED", + details: validation.errors.join(", "), + }, + }; + } + + // 3. 테이블 존재 여부 확인 + const tableExists = await this.checkTableExists(tableName); + if (tableExists) { + const errorMessage = `테이블 '${tableName}'이 이미 존재합니다.`; + + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "CREATE_TABLE", + tableName, + "TABLE_EXISTS", + false, + errorMessage + ); + + return { + success: false, + message: errorMessage, + error: { + code: "TABLE_EXISTS", + details: errorMessage, + }, + }; + } + + // 4. DDL 쿼리 생성 + const ddlQuery = this.generateCreateTableQuery(tableName, columns); + + // 5. 트랜잭션으로 안전하게 실행 + await prisma.$transaction(async (tx) => { + // 5-1. 테이블 생성 + await tx.$executeRawUnsafe(ddlQuery); + + // 5-2. 테이블 메타데이터 저장 + await this.saveTableMetadata(tx, tableName, description); + + // 5-3. 컬럼 메타데이터 저장 + await this.saveColumnMetadata(tx, tableName, columns); + }); + + // 6. 성공 로그 기록 + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "CREATE_TABLE", + tableName, + ddlQuery, + true + ); + + logger.info("테이블 생성 성공", { + tableName, + userId, + columnCount: columns.length, + }); + + // 테이블 생성 후 관련 캐시 무효화 + this.invalidateTableCache(tableName); + + return { + success: true, + message: `테이블 '${tableName}'이 성공적으로 생성되었습니다.`, + executedQuery: ddlQuery, + }; + } catch (error) { + const errorMessage = `테이블 생성 실패: ${(error as Error).message}`; + + // 실패 로그 기록 + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "CREATE_TABLE", + tableName, + `FAILED: ${(error as Error).message}`, + false, + errorMessage + ); + + logger.error("테이블 생성 실패:", { + tableName, + userId, + error: (error as Error).message, + stack: (error as Error).stack, + }); + + return { + success: false, + message: errorMessage, + error: { + code: "EXECUTION_FAILED", + details: (error as Error).message, + }, + }; + } + } + + /** + * 기존 테이블에 컬럼 추가 + */ + async addColumn( + tableName: string, + column: CreateColumnDefinition, + userCompanyCode: string, + userId: string + ): Promise { + // DDL 실행 시작 로그 + await DDLAuditLogger.logDDLStart( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + { column } + ); + + try { + // 1. 권한 검증 + this.validateSuperAdminPermission(userCompanyCode); + + // 2. 안전성 검증 + const validation = DDLSafetyValidator.validateColumnAddition( + tableName, + column + ); + if (!validation.isValid) { + const errorMessage = `컬럼 추가 검증 실패: ${validation.errors.join(", ")}`; + + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + "VALIDATION_FAILED", + false, + errorMessage + ); + + return { + success: false, + message: errorMessage, + error: { + code: "VALIDATION_FAILED", + details: validation.errors.join(", "), + }, + }; + } + + // 3. 테이블 존재 여부 확인 + const tableExists = await this.checkTableExists(tableName); + if (!tableExists) { + const errorMessage = `테이블 '${tableName}'이 존재하지 않습니다.`; + + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + "TABLE_NOT_EXISTS", + false, + errorMessage + ); + + return { + success: false, + message: errorMessage, + error: { + code: "TABLE_NOT_EXISTS", + details: errorMessage, + }, + }; + } + + // 4. 컬럼 존재 여부 확인 + const columnExists = await this.checkColumnExists(tableName, column.name); + if (columnExists) { + const errorMessage = `컬럼 '${column.name}'이 이미 존재합니다.`; + + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + "COLUMN_EXISTS", + false, + errorMessage + ); + + return { + success: false, + message: errorMessage, + error: { + code: "COLUMN_EXISTS", + details: errorMessage, + }, + }; + } + + // 5. DDL 쿼리 생성 + const ddlQuery = this.generateAddColumnQuery(tableName, column); + + // 6. 트랜잭션으로 안전하게 실행 + await prisma.$transaction(async (tx) => { + // 6-1. 컬럼 추가 + await tx.$executeRawUnsafe(ddlQuery); + + // 6-2. 컬럼 메타데이터 저장 + await this.saveColumnMetadata(tx, tableName, [column]); + }); + + // 7. 성공 로그 기록 + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + ddlQuery, + true + ); + + logger.info("컬럼 추가 성공", { + tableName, + columnName: column.name, + webType: column.webType, + userId, + }); + + // 컬럼 추가 후 관련 캐시 무효화 + this.invalidateTableCache(tableName); + + return { + success: true, + message: `컬럼 '${column.name}'이 성공적으로 추가되었습니다.`, + executedQuery: ddlQuery, + }; + } catch (error) { + const errorMessage = `컬럼 추가 실패: ${(error as Error).message}`; + + // 실패 로그 기록 + await DDLAuditLogger.logDDLExecution( + userId, + userCompanyCode, + "ADD_COLUMN", + tableName, + `FAILED: ${(error as Error).message}`, + false, + errorMessage + ); + + logger.error("컬럼 추가 실패:", { + tableName, + columnName: column.name, + userId, + error: (error as Error).message, + stack: (error as Error).stack, + }); + + return { + success: false, + message: errorMessage, + error: { + code: "EXECUTION_FAILED", + details: (error as Error).message, + }, + }; + } + } + + /** + * CREATE TABLE DDL 쿼리 생성 + */ + private generateCreateTableQuery( + tableName: string, + columns: CreateColumnDefinition[] + ): string { + // 사용자 정의 컬럼들 - 모두 VARCHAR(500)로 통일 + const columnDefinitions = columns + .map((col) => { + // 입력 타입과 관계없이 모든 컬럼을 VARCHAR(500)로 생성 + let definition = `"${col.name}" varchar(500)`; + + if (!col.nullable) { + definition += " NOT NULL"; + } + + if (col.defaultValue) { + definition += ` DEFAULT '${col.defaultValue}'`; + } + + return definition; + }) + .join(",\n "); + + // 기본 컬럼들 (날짜는 TIMESTAMP, 나머지는 VARCHAR) + const baseColumns = ` + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500), + "company_code" varchar(500)`; + + // 최종 CREATE TABLE 쿼리 + return ` +CREATE TABLE "${tableName}" (${baseColumns}, + ${columnDefinitions} +);`.trim(); + } + + /** + * ALTER TABLE ADD COLUMN DDL 쿼리 생성 + */ + private generateAddColumnQuery( + tableName: string, + column: CreateColumnDefinition + ): string { + // 새로 추가되는 컬럼도 VARCHAR(500)로 통일 + let definition = `"${column.name}" varchar(500)`; + + if (!column.nullable) { + definition += " NOT NULL"; + } + + if (column.defaultValue) { + definition += ` DEFAULT '${column.defaultValue}'`; + } + + return `ALTER TABLE "${tableName}" ADD COLUMN ${definition};`; + } + + /** + * 입력타입을 PostgreSQL 타입으로 매핑 (날짜는 TIMESTAMP, 나머지는 VARCHAR) + * 날짜 타입만 TIMESTAMP로, 나머지는 VARCHAR(500)로 통일 + */ + private mapInputTypeToPostgresType(inputType?: string): string { + switch (inputType) { + case "date": + return "timestamp"; + default: + // 날짜 외의 모든 타입은 VARCHAR(500)로 통일 + return "varchar(500)"; + } + } + + /** + * 레거시 지원: 웹타입을 PostgreSQL 타입으로 매핑 + * @deprecated 새로운 시스템에서는 mapInputTypeToPostgresType 사용 + */ + private mapWebTypeToPostgresType(webType: WebType, length?: number): string { + // 레거시 지원을 위해 유지하되, VARCHAR(500)로 통일 + logger.info(`레거시 웹타입 사용: ${webType} → varchar(500)로 변환`); + return "varchar(500)"; + } + + /** + * 테이블 메타데이터 저장 + */ + private async saveTableMetadata( + tx: any, + tableName: string, + description?: string + ): Promise { + await tx.table_labels.upsert({ + where: { table_name: tableName }, + update: { + table_label: tableName, + description: description || `사용자 생성 테이블: ${tableName}`, + updated_date: new Date(), + }, + create: { + table_name: tableName, + table_label: tableName, + description: description || `사용자 생성 테이블: ${tableName}`, + created_date: new Date(), + updated_date: new Date(), + }, + }); + } + + /** + * 컬럼 메타데이터 저장 + */ + private async saveColumnMetadata( + tx: any, + tableName: string, + columns: CreateColumnDefinition[] + ): Promise { + // 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성 + await tx.table_labels.upsert({ + where: { + table_name: tableName, + }, + update: { + updated_date: new Date(), + }, + create: { + table_name: tableName, + table_label: tableName, + description: `자동 생성된 테이블 메타데이터: ${tableName}`, + created_date: new Date(), + updated_date: new Date(), + }, + }); + + // 기본 컬럼들 정의 (모든 테이블에 자동으로 추가되는 시스템 컬럼) + const defaultColumns = [ + { + name: "id", + label: "ID", + inputType: "text", + description: "기본키 (자동생성)", + order: -5, + isVisible: true, + }, + { + name: "created_date", + label: "생성일시", + inputType: "date", + description: "레코드 생성일시", + order: -4, + isVisible: true, + }, + { + name: "updated_date", + label: "수정일시", + inputType: "date", + description: "레코드 수정일시", + order: -3, + isVisible: true, + }, + { + name: "writer", + label: "작성자", + inputType: "text", + description: "레코드 작성자", + order: -2, + isVisible: true, + }, + { + name: "company_code", + label: "회사코드", + inputType: "text", + description: "회사 구분 코드", + order: -1, + isVisible: true, + }, + ]; + + // 기본 컬럼들을 table_type_columns에 등록 + for (const defaultCol of defaultColumns) { + await tx.$executeRaw` + INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date + ) VALUES ( + ${tableName}, ${defaultCol.name}, ${defaultCol.inputType}, '{}', + 'Y', ${defaultCol.order}, now(), now() + ) + ON CONFLICT (table_name, column_name) + DO UPDATE SET + input_type = ${defaultCol.inputType}, + display_order = ${defaultCol.order}, + updated_date = now(); + `; + } + + // 사용자 정의 컬럼들을 table_type_columns에 등록 + for (let i = 0; i < columns.length; i++) { + const column = columns[i]; + const inputType = this.convertWebTypeToInputType( + column.webType || "text" + ); + + await tx.$executeRaw` + INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date + ) VALUES ( + ${tableName}, ${column.name}, ${inputType}, ${JSON.stringify(column.detailSettings || {})}, + 'Y', ${i}, now(), now() + ) + ON CONFLICT (table_name, column_name) + DO UPDATE SET + input_type = ${inputType}, + detail_settings = ${JSON.stringify(column.detailSettings || {})}, + display_order = ${i}, + updated_date = now(); + `; + } + + // 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성) + // 1. 기본 컬럼들을 column_labels에 등록 + for (const defaultCol of defaultColumns) { + await tx.column_labels.upsert({ + where: { + table_name_column_name: { + table_name: tableName, + column_name: defaultCol.name, + }, + }, + update: { + column_label: defaultCol.label, + input_type: defaultCol.inputType, + detail_settings: JSON.stringify({}), + description: defaultCol.description, + display_order: defaultCol.order, + is_visible: defaultCol.isVisible, + updated_date: new Date(), + }, + create: { + table_name: tableName, + column_name: defaultCol.name, + column_label: defaultCol.label, + input_type: defaultCol.inputType, + detail_settings: JSON.stringify({}), + description: defaultCol.description, + display_order: defaultCol.order, + is_visible: defaultCol.isVisible, + created_date: new Date(), + updated_date: new Date(), + }, + }); + } + + // 2. 사용자 정의 컬럼들을 column_labels에 등록 + 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, + input_type: this.convertWebTypeToInputType(column.webType || "text"), + 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, + input_type: this.convertWebTypeToInputType(column.webType || "text"), + 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 convertWebTypeToInputType(webType: string): string { + const webTypeToInputTypeMap: Record = { + // 텍스트 관련 + text: "text", + textarea: "text", + email: "text", + tel: "text", + url: "text", + password: "text", + + // 숫자 관련 + number: "number", + decimal: "number", + + // 날짜 관련 + date: "date", + datetime: "date", + time: "date", + + // 선택 관련 + select: "select", + dropdown: "select", + checkbox: "checkbox", + boolean: "checkbox", + radio: "radio", + + // 참조 관련 + code: "code", + entity: "entity", + + // 기타 + file: "text", + button: "text", + }; + + return webTypeToInputTypeMap[webType] || "text"; + } + + /** + * 권한 검증 (슈퍼관리자 확인) + */ + private validateSuperAdminPermission(userCompanyCode: string): void { + if (userCompanyCode !== "*") { + throw new Error("최고 관리자 권한이 필요합니다."); + } + } + + /** + * 테이블 존재 여부 확인 + */ + private async checkTableExists(tableName: string): Promise { + try { + const result = await prisma.$queryRawUnsafe( + ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ); + `, + tableName + ); + + return (result as any)[0]?.exists || false; + } catch (error) { + logger.error("테이블 존재 확인 오류:", error); + return false; + } + } + + /** + * 컬럼 존재 여부 확인 + */ + private async checkColumnExists( + tableName: string, + columnName: string + ): Promise { + try { + const result = await prisma.$queryRawUnsafe( + ` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + AND column_name = $2 + ); + `, + tableName, + columnName + ); + + return (result as any)[0]?.exists || false; + } catch (error) { + logger.error("컬럼 존재 확인 오류:", error); + return false; + } + } + + /** + * 생성된 테이블 정보 조회 + */ + async getCreatedTableInfo(tableName: string): Promise<{ + tableInfo: any; + columns: any[]; + } | null> { + try { + // 테이블 정보 조회 + const tableInfo = await prisma.table_labels.findUnique({ + where: { table_name: tableName }, + }); + + // 컬럼 정보 조회 + const columns = await prisma.column_labels.findMany({ + where: { table_name: tableName }, + orderBy: { display_order: "asc" }, + }); + + if (!tableInfo) { + return null; + } + + return { + tableInfo, + columns, + }; + } catch (error) { + logger.error("생성된 테이블 정보 조회 실패:", error); + return null; + } + } + + /** + * 테이블 관련 캐시 무효화 + * DDL 작업 후 호출하여 캐시된 데이터를 클리어 + */ + private invalidateTableCache(tableName: string): void { + try { + // 테이블 컬럼 관련 캐시 무효화 + const columnCacheDeleted = cache.deleteByPattern( + `table_columns:${tableName}` + ); + const countCacheDeleted = cache.deleteByPattern( + `table_column_count:${tableName}` + ); + cache.delete("table_list"); + + const totalDeleted = columnCacheDeleted + countCacheDeleted + 1; + + logger.info( + `테이블 캐시 무효화 완료: ${tableName}, 삭제된 키: ${totalDeleted}개` + ); + } catch (error) { + logger.warn(`테이블 캐시 무효화 실패: ${tableName}`, error); + } + } +} diff --git a/backend-node/src/services/ddlSafetyValidator.ts b/backend-node/src/services/ddlSafetyValidator.ts new file mode 100644 index 00000000..bacf2308 --- /dev/null +++ b/backend-node/src/services/ddlSafetyValidator.ts @@ -0,0 +1,390 @@ +/** + * DDL 안전성 검증 서비스 + * 테이블/컬럼 생성 전 모든 보안 검증을 수행 + */ + +import { + CreateColumnDefinition, + ValidationResult, + SYSTEM_TABLES, + RESERVED_WORDS, + RESERVED_COLUMNS, +} from "../types/ddl"; +import { logger } from "../utils/logger"; + +export class DDLSafetyValidator { + /** + * 테이블 생성 전 전체 검증 + */ + static validateTableCreation( + tableName: string, + columns: CreateColumnDefinition[] + ): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + try { + // 1. 테이블명 기본 검증 + const tableNameValidation = this.validateTableName(tableName); + if (!tableNameValidation.isValid) { + errors.push(...tableNameValidation.errors); + } + + // 2. 컬럼 기본 검증 + if (columns.length === 0) { + errors.push("최소 1개의 컬럼이 필요합니다."); + } + + // 3. 컬럼 목록 검증 + const columnsValidation = this.validateColumnList(columns); + if (!columnsValidation.isValid) { + errors.push(...columnsValidation.errors); + } + if (columnsValidation.warnings) { + warnings.push(...columnsValidation.warnings); + } + + // 4. 컬럼명 중복 검증 + const duplicateValidation = this.validateColumnDuplication(columns); + if (!duplicateValidation.isValid) { + errors.push(...duplicateValidation.errors); + } + + logger.info("테이블 생성 검증 완료", { + tableName, + columnCount: columns.length, + errorCount: errors.length, + warningCount: warnings.length, + }); + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } catch (error) { + logger.error("테이블 생성 검증 중 오류 발생:", error); + return { + isValid: false, + errors: ["테이블 생성 검증 중 내부 오류가 발생했습니다."], + }; + } + } + + /** + * 컬럼 추가 전 검증 + */ + static validateColumnAddition( + tableName: string, + column: CreateColumnDefinition + ): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + try { + // 1. 테이블명 검증 (시스템 테이블 확인) + if (this.isSystemTable(tableName)) { + errors.push( + `'${tableName}'은 시스템 테이블이므로 컬럼을 추가할 수 없습니다.` + ); + } + + // 2. 컬럼 정의 검증 + const columnValidation = this.validateSingleColumn(column); + if (!columnValidation.isValid) { + errors.push(...columnValidation.errors); + } + if (columnValidation.warnings) { + warnings.push(...columnValidation.warnings); + } + + logger.info("컬럼 추가 검증 완료", { + tableName, + columnName: column.name, + webType: column.webType, + errorCount: errors.length, + }); + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } catch (error) { + logger.error("컬럼 추가 검증 중 오류 발생:", error); + return { + isValid: false, + errors: ["컬럼 추가 검증 중 내부 오류가 발생했습니다."], + }; + } + } + + /** + * 테이블명 검증 + */ + private static validateTableName(tableName: string): ValidationResult { + const errors: string[] = []; + + // 1. 기본 형식 검증 + if (!this.isValidTableName(tableName)) { + errors.push( + "유효하지 않은 테이블명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다." + ); + } + + // 2. 길이 검증 + if (tableName.length > 63) { + errors.push("테이블명은 63자를 초과할 수 없습니다."); + } + + if (tableName.length < 2) { + errors.push("테이블명은 최소 2자 이상이어야 합니다."); + } + + // 3. 시스템 테이블 보호 + if (this.isSystemTable(tableName)) { + errors.push( + `'${tableName}'은 시스템 테이블명으로 사용할 수 없습니다. 다른 이름을 선택해주세요.` + ); + } + + // 4. 예약어 검증 + if (this.isReservedWord(tableName)) { + errors.push( + `'${tableName}'은 SQL 예약어이므로 테이블명으로 사용할 수 없습니다.` + ); + } + + // 5. 일반적인 네이밍 컨벤션 검증 + if (tableName.startsWith("_") || tableName.endsWith("_")) { + errors.push("테이블명은 언더스코어로 시작하거나 끝날 수 없습니다."); + } + + if (tableName.includes("__")) { + errors.push("테이블명에 연속된 언더스코어는 사용할 수 없습니다."); + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * 컬럼 목록 검증 + */ + private static validateColumnList( + columns: CreateColumnDefinition[] + ): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + for (let i = 0; i < columns.length; i++) { + const column = columns[i]; + const columnValidation = this.validateSingleColumn(column, i + 1); + + if (!columnValidation.isValid) { + errors.push(...columnValidation.errors); + } + + if (columnValidation.warnings) { + warnings.push(...columnValidation.warnings); + } + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } + + /** + * 개별 컬럼 검증 + */ + private static validateSingleColumn( + column: CreateColumnDefinition, + position?: number + ): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + const prefix = position + ? `컬럼 ${position}(${column.name}): ` + : `컬럼 '${column.name}': `; + + // 1. 컬럼명 기본 검증 + if (!column.name || column.name.trim() === "") { + errors.push(`${prefix}컬럼명은 필수입니다.`); + return { isValid: false, errors }; + } + + if (!this.isValidColumnName(column.name)) { + errors.push( + `${prefix}유효하지 않은 컬럼명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.` + ); + } + + // 2. 길이 검증 + if (column.name.length > 63) { + errors.push(`${prefix}컬럼명은 63자를 초과할 수 없습니다.`); + } + + if (column.name.length < 2) { + errors.push(`${prefix}컬럼명은 최소 2자 이상이어야 합니다.`); + } + + // 3. 예약된 컬럼명 검증 + if (this.isReservedColumnName(column.name)) { + errors.push( + `${prefix}'${column.name}'은 예약된 컬럼명입니다. 기본 컬럼(id, created_date, updated_date, company_code)과 중복됩니다.` + ); + } + + // 4. SQL 예약어 검증 + if (this.isReservedWord(column.name)) { + errors.push( + `${prefix}'${column.name}'은 SQL 예약어이므로 컬럼명으로 사용할 수 없습니다.` + ); + } + + // 5. 웹타입 검증 + if (!column.webType) { + errors.push(`${prefix}웹타입이 지정되지 않았습니다.`); + } + + // 6. 길이 설정 검증 (text, code 타입에서만 허용) + if (column.length !== undefined) { + if ( + !["text", "code", "email", "tel", "select", "radio"].includes( + column.webType || "text" + ) + ) { + warnings.push( + `${prefix}${column.webType || "text"} 타입에서는 길이 설정이 무시됩니다.` + ); + } else if (column.length <= 0 || column.length > 65535) { + errors.push(`${prefix}길이는 1 이상 65535 이하여야 합니다.`); + } + } + + // 7. 네이밍 컨벤션 검증 + if (column.name.startsWith("_") || column.name.endsWith("_")) { + warnings.push( + `${prefix}컬럼명이 언더스코어로 시작하거나 끝나는 것은 권장하지 않습니다.` + ); + } + + if (column.name.includes("__")) { + errors.push(`${prefix}컬럼명에 연속된 언더스코어는 사용할 수 없습니다.`); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } + + /** + * 컬럼명 중복 검증 + */ + private static validateColumnDuplication( + columns: CreateColumnDefinition[] + ): ValidationResult { + const errors: string[] = []; + const columnNames = columns.map((col) => col.name.toLowerCase()); + const seen = new Set(); + const duplicates = new Set(); + + for (const name of columnNames) { + if (seen.has(name)) { + duplicates.add(name); + } else { + seen.add(name); + } + } + + if (duplicates.size > 0) { + errors.push( + `중복된 컬럼명이 있습니다: ${Array.from(duplicates).join(", ")}` + ); + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * 테이블명 유효성 검증 (정규식) + */ + private static isValidTableName(tableName: string): boolean { + const tableNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + return tableNameRegex.test(tableName); + } + + /** + * 컬럼명 유효성 검증 (정규식) + */ + private static isValidColumnName(columnName: string): boolean { + const columnNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + return columnNameRegex.test(columnName) && columnName.length <= 63; + } + + /** + * 시스템 테이블 확인 + */ + private static isSystemTable(tableName: string): boolean { + return SYSTEM_TABLES.includes(tableName.toLowerCase() as any); + } + + /** + * SQL 예약어 확인 + */ + private static isReservedWord(word: string): boolean { + return RESERVED_WORDS.includes(word.toLowerCase() as any); + } + + /** + * 예약된 컬럼명 확인 + */ + private static isReservedColumnName(columnName: string): boolean { + return RESERVED_COLUMNS.includes(columnName.toLowerCase() as any); + } + + /** + * 전체 검증 통계 생성 + */ + static generateValidationReport( + tableName: string, + columns: CreateColumnDefinition[] + ): { + tableName: string; + totalColumns: number; + validationResult: ValidationResult; + summary: string; + } { + const validationResult = this.validateTableCreation(tableName, columns); + + let summary = `테이블 '${tableName}' 검증 완료. `; + summary += `컬럼 ${columns.length}개 중 `; + + if (validationResult.isValid) { + summary += "모든 검증 통과."; + } else { + summary += `${validationResult.errors.length}개 오류 발견.`; + } + + if (validationResult.warnings && validationResult.warnings.length > 0) { + summary += ` ${validationResult.warnings.length}개 경고 있음.`; + } + + return { + tableName, + totalColumns: columns.length, + validationResult, + summary, + }; + } +} diff --git a/backend-node/src/services/enhancedDynamicFormService.ts b/backend-node/src/services/enhancedDynamicFormService.ts new file mode 100644 index 00000000..2ffbfdad --- /dev/null +++ b/backend-node/src/services/enhancedDynamicFormService.ts @@ -0,0 +1,786 @@ +/** + * 개선된 동적 폼 서비스 + * 타입 안전성과 검증 강화 + */ + +import { PrismaClient } from "@prisma/client"; +import { + WebType, + DynamicWebType, + normalizeWebType, + isValidWebType, + WEB_TYPE_TO_POSTGRES_CONVERTER, + WEB_TYPE_VALIDATION_PATTERNS, +} from "../types/unified-web-types"; +import { DataflowControlService } from "./dataflowControlService"; + +const prisma = new PrismaClient(); + +// 테이블 컬럼 정보 +export interface TableColumn { + column_name: string; + data_type: string; + is_nullable: string; + column_default: any; + character_maximum_length?: number; + numeric_precision?: number; + numeric_scale?: number; +} + +// 컬럼 웹타입 정보 +export interface ColumnWebTypeInfo { + columnName: string; + webType: WebType; + isRequired: boolean; + validationRules?: Record; + defaultValue?: any; +} + +// 폼 데이터 검증 결과 +export interface FormValidationResult { + isValid: boolean; + errors: FormValidationError[]; + warnings: FormValidationWarning[]; + transformedData: Record; +} + +export interface FormValidationError { + field: string; + code: string; + message: string; + value?: any; +} + +export interface FormValidationWarning { + field: string; + code: string; + message: string; + suggestion?: string; +} + +// 저장 결과 +export interface FormDataResult { + success: boolean; + message: string; + data?: any; + affectedRows?: number; + insertedId?: any; + validationResult?: FormValidationResult; +} + +export class EnhancedDynamicFormService { + private dataflowControlService = new DataflowControlService(); + private columnCache = new Map(); + private webTypeCache = new Map(); + + /** + * 폼 데이터 저장 (메인 메서드) + */ + async saveFormData( + screenId: number, + tableName: string, + data: Record + ): Promise { + const startTime = Date.now(); + + try { + console.log(`🚀 개선된 폼 저장 시작: ${tableName}`, { + screenId, + dataKeys: Object.keys(data), + timestamp: new Date().toISOString(), + }); + + // 1. 테이블 존재 여부 확인 + const tableExists = await this.validateTableExists(tableName); + if (!tableExists) { + return { + success: false, + message: `테이블 '${tableName}'이 존재하지 않습니다.`, + }; + } + + // 2. 스키마 정보 로드 + const [tableColumns, columnWebTypes] = await Promise.all([ + this.getTableColumns(tableName), + this.getColumnWebTypes(tableName), + ]); + + // 3. 폼 데이터 검증 + const validationResult = await this.validateFormData( + data, + tableColumns, + columnWebTypes, + tableName + ); + + if (!validationResult.isValid) { + console.error("❌ 폼 데이터 검증 실패:", validationResult.errors); + return { + success: false, + message: this.formatValidationErrors(validationResult.errors), + validationResult, + }; + } + + // 4. 데이터 저장 수행 + const saveResult = await this.performDataSave( + tableName, + validationResult.transformedData, + tableColumns + ); + + const duration = Date.now() - startTime; + console.log(`✅ 폼 저장 완료: ${duration}ms`); + + return { + success: true, + message: "데이터가 성공적으로 저장되었습니다.", + data: saveResult.data, + affectedRows: saveResult.affectedRows, + insertedId: saveResult.insertedId, + validationResult, + }; + } catch (error: any) { + console.error("❌ 폼 저장 중 오류:", error); + + return { + success: false, + message: this.formatErrorMessage(error), + data: { error: error.message, stack: error.stack }, + }; + } + } + + /** + * 테이블 존재 여부 확인 + */ + private async validateTableExists(tableName: string): Promise { + try { + const result = await prisma.$queryRawUnsafe( + ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = $1 + ) as exists + `, + tableName + ); + + return (result as any)[0]?.exists || false; + } catch (error) { + console.error(`❌ 테이블 존재 여부 확인 실패: ${tableName}`, error); + return false; + } + } + + /** + * 테이블 컬럼 정보 조회 (캐시 포함) + */ + private async getTableColumns(tableName: string): Promise { + // 캐시 확인 + const cached = this.columnCache.get(tableName); + if (cached) { + return cached; + } + + try { + const columns = (await prisma.$queryRawUnsafe( + ` + SELECT + column_name, + data_type, + is_nullable, + column_default, + character_maximum_length, + numeric_precision, + numeric_scale + FROM information_schema.columns + WHERE table_name = $1 + ORDER BY ordinal_position + `, + tableName + )) as TableColumn[]; + + // 캐시 저장 (10분) + this.columnCache.set(tableName, columns); + setTimeout(() => this.columnCache.delete(tableName), 10 * 60 * 1000); + + return columns; + } catch (error) { + console.error(`❌ 테이블 컬럼 정보 조회 실패: ${tableName}`, error); + throw new Error(`테이블 컬럼 정보를 조회할 수 없습니다: ${error}`); + } + } + + /** + * 컬럼 웹타입 정보 조회 + */ + private async getColumnWebTypes( + tableName: string + ): Promise { + // 캐시 확인 + const cached = this.webTypeCache.get(tableName); + if (cached) { + return cached; + } + + try { + // table_type_columns에서 웹타입 정보 조회 + const webTypeData = (await prisma.$queryRawUnsafe( + ` + SELECT + column_name, + web_type, + is_nullable, + detail_settings + FROM table_type_columns + WHERE table_name = $1 + `, + tableName + )) as any[]; + + const columnWebTypes: ColumnWebTypeInfo[] = webTypeData.map((row) => ({ + columnName: row.column_name, + webType: normalizeWebType(row.web_type || "text"), + isRequired: row.is_nullable === "N", + validationRules: this.parseDetailSettings(row.detail_settings), + defaultValue: null, + })); + + // 캐시 저장 (10분) + this.webTypeCache.set(tableName, columnWebTypes); + setTimeout(() => this.webTypeCache.delete(tableName), 10 * 60 * 1000); + + return columnWebTypes; + } catch (error) { + console.error(`❌ 컬럼 웹타입 정보 조회 실패: ${tableName}`, error); + // 실패 시 빈 배열 반환 (기본 검증만 수행) + return []; + } + } + + /** + * 상세 설정 파싱 + */ + private parseDetailSettings( + detailSettings: string | null + ): Record { + if (!detailSettings) return {}; + + try { + return JSON.parse(detailSettings); + } catch { + return {}; + } + } + + /** + * 폼 데이터 검증 + */ + private async validateFormData( + data: Record, + tableColumns: TableColumn[], + columnWebTypes: ColumnWebTypeInfo[], + tableName: string + ): Promise { + const errors: FormValidationError[] = []; + const warnings: FormValidationWarning[] = []; + const transformedData: Record = {}; + + const columnMap = new Map( + tableColumns.map((col) => [col.column_name, col]) + ); + const webTypeMap = new Map(columnWebTypes.map((wt) => [wt.columnName, wt])); + + console.log(`📋 폼 데이터 검증 시작: ${tableName}`, { + inputFields: Object.keys(data).length, + tableColumns: tableColumns.length, + webTypeColumns: columnWebTypes.length, + }); + + // 입력된 각 필드 검증 + for (const [fieldName, value] of Object.entries(data)) { + const column = columnMap.get(fieldName); + const webTypeInfo = webTypeMap.get(fieldName); + + // 1. 컬럼 존재 여부 확인 + if (!column) { + errors.push({ + field: fieldName, + code: "COLUMN_NOT_EXISTS", + message: `테이블 '${tableName}'에 '${fieldName}' 컬럼이 존재하지 않습니다.`, + value, + }); + continue; + } + + // 2. 필수값 검증 + if (webTypeInfo?.isRequired && this.isEmptyValue(value)) { + errors.push({ + field: fieldName, + code: "REQUIRED_FIELD", + message: `'${fieldName}'은(는) 필수 입력 항목입니다.`, + value, + }); + continue; + } + + // 3. 웹타입별 검증 및 변환 + if (webTypeInfo?.webType) { + const validationResult = this.validateFieldByWebType( + fieldName, + value, + webTypeInfo.webType, + webTypeInfo.validationRules + ); + + if (!validationResult.isValid) { + errors.push(validationResult.error!); + continue; + } + + transformedData[fieldName] = validationResult.transformedValue; + } else { + // 웹타입 정보가 없는 경우 DB 타입 기반 변환 + transformedData[fieldName] = this.convertValueForPostgreSQL( + value, + column.data_type + ); + } + + // 4. DB 제약조건 검증 + const constraintValidation = this.validateDatabaseConstraints( + fieldName, + transformedData[fieldName], + column + ); + + if (!constraintValidation.isValid) { + errors.push(constraintValidation.error!); + continue; + } + } + + // 필수 컬럼 누락 확인 + const requiredColumns = columnWebTypes.filter((wt) => wt.isRequired); + for (const requiredCol of requiredColumns) { + if ( + !(requiredCol.columnName in data) || + this.isEmptyValue(data[requiredCol.columnName]) + ) { + if (!errors.some((e) => e.field === requiredCol.columnName)) { + errors.push({ + field: requiredCol.columnName, + code: "MISSING_REQUIRED_FIELD", + message: `필수 입력 항목 '${requiredCol.columnName}'이 누락되었습니다.`, + }); + } + } + } + + console.log(`📋 폼 데이터 검증 완료:`, { + errors: errors.length, + warnings: warnings.length, + transformedFields: Object.keys(transformedData).length, + }); + + return { + isValid: errors.length === 0, + errors, + warnings, + transformedData, + }; + } + + /** + * 웹타입별 필드 검증 + */ + private validateFieldByWebType( + fieldName: string, + value: any, + webType: WebType, + validationRules?: Record + ): { isValid: boolean; error?: FormValidationError; transformedValue?: any } { + // 빈 값 처리 + if (this.isEmptyValue(value)) { + return { isValid: true, transformedValue: null }; + } + + // 웹타입 유효성 확인 + if (!isValidWebType(webType)) { + return { + isValid: false, + error: { + field: fieldName, + code: "INVALID_WEB_TYPE", + message: `'${fieldName}'의 웹타입 '${webType}'이 올바르지 않습니다.`, + value, + }, + }; + } + + // 패턴 검증 + const pattern = WEB_TYPE_VALIDATION_PATTERNS[webType]; + if (pattern && !pattern.test(String(value))) { + return { + isValid: false, + error: { + field: fieldName, + code: "INVALID_FORMAT", + message: `'${fieldName}'의 형식이 올바르지 않습니다.`, + value, + }, + }; + } + + // 값 변환 + try { + const converter = WEB_TYPE_TO_POSTGRES_CONVERTER[webType]; + const transformedValue = converter ? converter(value) : value; + + return { isValid: true, transformedValue }; + } catch (error) { + return { + isValid: false, + error: { + field: fieldName, + code: "CONVERSION_ERROR", + message: `'${fieldName}' 값 변환 중 오류가 발생했습니다: ${error}`, + value, + }, + }; + } + } + + /** + * 데이터베이스 제약조건 검증 + */ + private validateDatabaseConstraints( + fieldName: string, + value: any, + column: TableColumn + ): { isValid: boolean; error?: FormValidationError } { + // NULL 제약조건 + if ( + column.is_nullable === "NO" && + (value === null || value === undefined) + ) { + return { + isValid: false, + error: { + field: fieldName, + code: "NOT_NULL_VIOLATION", + message: `'${fieldName}'에는 NULL 값을 입력할 수 없습니다.`, + value, + }, + }; + } + + // 문자열 길이 제약조건 + if (column.character_maximum_length && typeof value === "string") { + if (value.length > column.character_maximum_length) { + return { + isValid: false, + error: { + field: fieldName, + code: "STRING_TOO_LONG", + message: `'${fieldName}'의 길이는 최대 ${column.character_maximum_length}자까지 입력할 수 있습니다.`, + value, + }, + }; + } + } + + // 숫자 정밀도 검증 + if (column.numeric_precision && typeof value === "number") { + const totalDigits = Math.abs(value).toString().replace(".", "").length; + if (totalDigits > column.numeric_precision) { + return { + isValid: false, + error: { + field: fieldName, + code: "NUMERIC_OVERFLOW", + message: `'${fieldName}'의 숫자 자릿수가 허용 범위를 초과했습니다.`, + value, + }, + }; + } + } + + return { isValid: true }; + } + + /** + * 빈 값 확인 + */ + private isEmptyValue(value: any): boolean { + return ( + value === null || + value === undefined || + value === "" || + (Array.isArray(value) && value.length === 0) + ); + } + + /** + * 데이터 저장 수행 + */ + private async performDataSave( + tableName: string, + data: Record, + tableColumns: TableColumn[] + ): Promise<{ data?: any; affectedRows: number; insertedId?: any }> { + try { + // Primary Key 확인 + const primaryKeys = await this.getPrimaryKeys(tableName); + const hasExistingRecord = + primaryKeys.length > 0 && + primaryKeys.every((pk) => data[pk] !== undefined && data[pk] !== null); + + if (hasExistingRecord) { + // UPDATE 수행 + return await this.performUpdate(tableName, data, primaryKeys); + } else { + // INSERT 수행 + return await this.performInsert(tableName, data); + } + } catch (error) { + console.error(`❌ 데이터 저장 실패: ${tableName}`, error); + throw error; + } + } + + /** + * Primary Key 조회 + */ + private async getPrimaryKeys(tableName: string): Promise { + try { + const result = (await prisma.$queryRawUnsafe( + ` + SELECT column_name + FROM information_schema.key_column_usage + WHERE table_name = $1 + AND constraint_name LIKE '%_pkey' + `, + tableName + )) as any[]; + + return result.map((row) => row.column_name); + } catch (error) { + console.error(`❌ Primary Key 조회 실패: ${tableName}`, error); + return []; + } + } + + /** + * INSERT 수행 + */ + private async performInsert( + tableName: string, + data: Record + ): Promise<{ data?: any; affectedRows: number; insertedId?: any }> { + const columns = Object.keys(data); + const values = Object.values(data); + const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); + + const insertQuery = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + RETURNING * + `; + + console.log(`📝 INSERT 쿼리 실행: ${tableName}`, { + columns: columns.length, + query: insertQuery.replace(/\n\s+/g, " "), + }); + + const result = (await prisma.$queryRawUnsafe( + insertQuery, + ...values + )) as any[]; + + return { + data: result[0], + affectedRows: result.length, + insertedId: result[0]?.id || result[0], + }; + } + + /** + * UPDATE 수행 + */ + private async performUpdate( + tableName: string, + data: Record, + primaryKeys: string[] + ): Promise<{ data?: any; affectedRows: number; insertedId?: any }> { + const updateColumns = Object.keys(data).filter( + (col) => !primaryKeys.includes(col) + ); + const whereColumns = primaryKeys.filter((pk) => data[pk] !== undefined); + + if (updateColumns.length === 0) { + throw new Error("업데이트할 컬럼이 없습니다."); + } + + const setClause = updateColumns + .map((col, index) => `${col} = $${index + 1}`) + .join(", "); + + const whereClause = whereColumns + .map((col, index) => `${col} = $${updateColumns.length + index + 1}`) + .join(" AND "); + + const updateValues = [ + ...updateColumns.map((col) => data[col]), + ...whereColumns.map((col) => data[col]), + ]; + + const updateQuery = ` + UPDATE ${tableName} + SET ${setClause} + WHERE ${whereClause} + RETURNING * + `; + + console.log(`📝 UPDATE 쿼리 실행: ${tableName}`, { + updateColumns: updateColumns.length, + whereColumns: whereColumns.length, + query: updateQuery.replace(/\n\s+/g, " "), + }); + + const result = (await prisma.$queryRawUnsafe( + updateQuery, + ...updateValues + )) as any[]; + + return { + data: result[0], + affectedRows: result.length, + }; + } + + /** + * PostgreSQL 타입 변환 (레거시 지원) + */ + private convertValueForPostgreSQL(value: any, dataType: string): any { + if (value === null || value === undefined || value === "") { + return null; + } + + const lowerDataType = dataType.toLowerCase(); + + // 숫자 타입 처리 + if ( + lowerDataType.includes("integer") || + lowerDataType.includes("bigint") || + lowerDataType.includes("serial") + ) { + return parseInt(value) || null; + } + + if ( + lowerDataType.includes("numeric") || + lowerDataType.includes("decimal") || + lowerDataType.includes("real") || + lowerDataType.includes("double") + ) { + return parseFloat(value) || null; + } + + // 불린 타입 처리 + if (lowerDataType.includes("boolean")) { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + return value.toLowerCase() === "true" || value === "1"; + } + return Boolean(value); + } + + // 날짜/시간 타입 처리 + if ( + lowerDataType.includes("timestamp") || + lowerDataType.includes("datetime") + ) { + const date = new Date(value); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + + if (lowerDataType.includes("date")) { + const date = new Date(value); + return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0]; + } + + // JSON 타입 처리 + if (lowerDataType.includes("json")) { + if (typeof value === "string") { + try { + JSON.parse(value); + return value; + } catch { + return JSON.stringify(value); + } + } + return JSON.stringify(value); + } + + return String(value); + } + + /** + * 오류 메시지 포맷팅 + */ + private formatValidationErrors(errors: FormValidationError[]): string { + if (errors.length === 0) return "알 수 없는 오류가 발생했습니다."; + if (errors.length === 1) return errors[0].message; + + return `다음 오류들을 수정해주세요:\n• ${errors.map((e) => e.message).join("\n• ")}`; + } + + /** + * 시스템 오류 메시지 포맷팅 + */ + private formatErrorMessage(error: any): string { + if (error.code === "23505") { + return "중복된 데이터가 이미 존재합니다."; + } + + if (error.code === "23503") { + return "참조 무결성 제약조건을 위반했습니다."; + } + + if (error.code === "23502") { + return "필수 입력 항목이 누락되었습니다."; + } + + if ( + error.message?.includes("relation") && + error.message?.includes("does not exist") + ) { + return "지정된 테이블이 존재하지 않습니다."; + } + + return `저장 중 오류가 발생했습니다: ${error.message || error}`; + } + + /** + * 캐시 클리어 + */ + public clearCache(): void { + this.columnCache.clear(); + this.webTypeCache.clear(); + console.log("🧹 동적 폼 서비스 캐시가 클리어되었습니다."); + } + + /** + * 테이블별 캐시 클리어 + */ + public clearTableCache(tableName: string): void { + this.columnCache.delete(tableName); + this.webTypeCache.delete(tableName); + console.log(`🧹 테이블 '${tableName}' 캐시가 클리어되었습니다.`); + } +} + +// 싱글톤 인스턴스 +export const enhancedDynamicFormService = new EnhancedDynamicFormService(); diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts index 8039ebb0..bd9f8e86 100644 --- a/backend-node/src/services/externalDbConnectionService.ts +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -9,6 +9,7 @@ import { TableInfo, } from "../types/externalDbTypes"; import { PasswordEncryption } from "../utils/passwordEncryption"; +import { DbConnectionManager } from "./dbConnectionManager"; const prisma = new PrismaClient(); @@ -321,8 +322,6 @@ export class ExternalDbConnectionService { static async testConnectionById( id: number ): Promise { - const startTime = Date.now(); - try { // 저장된 연결 정보 조회 const connection = await prisma.external_db_connections.findUnique({ @@ -353,40 +352,20 @@ export class ExternalDbConnectionService { }; } - // 테스트용 데이터 준비 - const testData = { - db_type: connection.db_type, + // 연결 설정 준비 + const config = { host: connection.host, port: connection.port, - database_name: connection.database_name, - username: connection.username, + database: connection.database_name, + user: connection.username, password: decryptedPassword, - connection_timeout: connection.connection_timeout || undefined, - ssl_enabled: connection.ssl_enabled || undefined + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false }; - // 실제 연결 테스트 수행 - switch (connection.db_type.toLowerCase()) { - case "postgresql": - return await this.testPostgreSQLConnection(testData, startTime); - case "mysql": - return await this.testMySQLConnection(testData, startTime); - case "oracle": - return await this.testOracleConnection(testData, startTime); - case "mssql": - return await this.testMSSQLConnection(testData, startTime); - case "sqlite": - return await this.testSQLiteConnection(testData, startTime); - default: - return { - success: false, - message: `지원하지 않는 데이터베이스 타입입니다: ${testData.db_type}`, - error: { - code: "UNSUPPORTED_DB_TYPE", - details: `${testData.db_type} 타입은 현재 지원하지 않습니다.`, - }, - }; - } + // DbConnectionManager를 통한 연결 테스트 + return await DbConnectionManager.testConnection(id, connection.db_type, config); } catch (error) { return { success: false, @@ -399,132 +378,6 @@ export class ExternalDbConnectionService { } } - /** - * PostgreSQL 연결 테스트 - */ - private static async testPostgreSQLConnection( - testData: any, - startTime: number - ): Promise { - const { Client } = await import("pg"); - const client = new Client({ - host: testData.host, - port: testData.port, - database: testData.database_name, - user: testData.username, - password: testData.password, - connectionTimeoutMillis: (testData.connection_timeout || 30) * 1000, - ssl: testData.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false, - }); - - try { - await client.connect(); - const result = await client.query( - "SELECT version(), pg_database_size(current_database()) as size" - ); - const responseTime = Date.now() - startTime; - - await client.end(); - - return { - success: true, - message: "PostgreSQL 연결이 성공했습니다.", - details: { - response_time: responseTime, - server_version: result.rows[0]?.version || "알 수 없음", - database_size: this.formatBytes( - parseInt(result.rows[0]?.size || "0") - ), - }, - }; - } catch (error) { - try { - await client.end(); - } catch (endError) { - // 연결 종료 오류는 무시 - } - - return { - success: false, - message: "PostgreSQL 연결에 실패했습니다.", - error: { - code: "CONNECTION_FAILED", - details: error instanceof Error ? error.message : "알 수 없는 오류", - }, - }; - } - } - - /** - * MySQL 연결 테스트 (모의 구현) - */ - private static async testMySQLConnection( - testData: any, - startTime: number - ): Promise { - // MySQL 라이브러리가 없으므로 모의 구현 - return { - success: false, - message: "MySQL 연결 테스트는 현재 지원하지 않습니다.", - error: { - code: "NOT_IMPLEMENTED", - details: "MySQL 라이브러리가 설치되지 않았습니다.", - }, - }; - } - - /** - * Oracle 연결 테스트 (모의 구현) - */ - private static async testOracleConnection( - testData: any, - startTime: number - ): Promise { - return { - success: false, - message: "Oracle 연결 테스트는 현재 지원하지 않습니다.", - error: { - code: "NOT_IMPLEMENTED", - details: "Oracle 라이브러리가 설치되지 않았습니다.", - }, - }; - } - - /** - * SQL Server 연결 테스트 (모의 구현) - */ - private static async testMSSQLConnection( - testData: any, - startTime: number - ): Promise { - return { - success: false, - message: "SQL Server 연결 테스트는 현재 지원하지 않습니다.", - error: { - code: "NOT_IMPLEMENTED", - details: "SQL Server 라이브러리가 설치되지 않았습니다.", - }, - }; - } - - /** - * SQLite 연결 테스트 (모의 구현) - */ - private static async testSQLiteConnection( - testData: any, - startTime: number - ): Promise { - return { - success: false, - message: "SQLite 연결 테스트는 현재 지원하지 않습니다.", - error: { - code: "NOT_IMPLEMENTED", - details: - "SQLite는 파일 기반이므로 네트워크 연결 테스트가 불가능합니다.", - }, - }; - } - /** * 바이트 크기를 읽기 쉬운 형태로 변환 */ @@ -622,36 +475,26 @@ export class ExternalDbConnectionService { }; } - // DB 타입에 따른 쿼리 실행 - switch (connection.db_type.toLowerCase()) { - case "postgresql": - return await this.executePostgreSQLQuery(connection, decryptedPassword, query); - case "mysql": - return { - success: false, - message: "MySQL 쿼리 실행은 현재 지원하지 않습니다." - }; - case "oracle": - return { - success: false, - message: "Oracle 쿼리 실행은 현재 지원하지 않습니다." - }; - case "mssql": - return { - success: false, - message: "SQL Server 쿼리 실행은 현재 지원하지 않습니다." - }; - case "sqlite": - return { - success: false, - message: "SQLite 쿼리 실행은 현재 지원하지 않습니다." - }; - default: - return { - success: false, - message: `지원하지 않는 데이터베이스 타입입니다: ${connection.db_type}` - }; - } + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + // DbConnectionManager를 통한 쿼리 실행 + const result = await DbConnectionManager.executeQuery(id, connection.db_type, config, query); + + return { + success: true, + message: "쿼리가 성공적으로 실행되었습니다.", + data: result.rows + }; } catch (error) { console.error("쿼리 실행 오류:", error); return { @@ -740,15 +583,26 @@ export class ExternalDbConnectionService { }; } - switch (connection.db_type.toLowerCase()) { - case "postgresql": - return await this.getPostgreSQLTables(connection, decryptedPassword); - default: - return { - success: false, - message: `지원하지 않는 데이터베이스 타입입니다: ${connection.db_type}` - }; - } + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + // DbConnectionManager를 통한 테이블 목록 조회 + const tables = await DbConnectionManager.getTables(id, connection.db_type, config); + + return { + success: true, + message: "테이블 목록을 조회했습니다.", + data: tables + }; } catch (error) { console.error("테이블 목록 조회 오류:", error); return { diff --git a/backend-node/src/services/inputTypeService.ts b/backend-node/src/services/inputTypeService.ts new file mode 100644 index 00000000..57bf80d4 --- /dev/null +++ b/backend-node/src/services/inputTypeService.ts @@ -0,0 +1,282 @@ +/** + * 입력 타입 처리 서비스 + * VARCHAR 통일 방식에서 입력 타입별 형변환 및 검증 처리 + */ + +import { InputType } from "../types/input-types"; +import { logger } from "../utils/logger"; + +export interface ValidationResult { + isValid: boolean; + message?: string; + convertedValue?: string; +} + +export class InputTypeService { + /** + * 데이터 저장 전 형변환 (화면 입력값 → DB 저장값) + * 모든 값을 VARCHAR(500)에 저장하기 위해 문자열로 변환 + */ + static convertForStorage(value: any, inputType: InputType): string { + if (value === null || value === undefined) { + return ""; + } + + try { + switch (inputType) { + case "text": + case "select": + case "radio": + return String(value).trim(); + + case "number": + if (value === "" || value === null || value === undefined) { + return "0"; + } + const num = parseFloat(String(value)); + return isNaN(num) ? "0" : String(num); + + case "date": + if (!value || value === "") { + return ""; + } + const date = new Date(value); + if (isNaN(date.getTime())) { + logger.warn(`Invalid date value: ${value}`); + return ""; + } + return date.toISOString().split("T")[0]; // YYYY-MM-DD 형식 + + case "checkbox": + // 다양한 형태의 true 값을 "Y"로, 나머지는 "N"으로 변환 + const truthyValues = ["true", "1", "Y", "yes", "on", true, 1]; + return truthyValues.includes(value) ? "Y" : "N"; + + case "code": + case "entity": + return String(value || "").trim(); + + default: + return String(value); + } + } catch (error) { + logger.error(`Error converting value for storage: ${error}`, { + value, + inputType, + }); + return String(value || ""); + } + } + + /** + * 화면 표시용 형변환 (DB 저장값 → 화면 표시값) + * VARCHAR에서 읽어온 문자열을 적절한 타입으로 변환 + */ + static convertForDisplay(value: string, inputType: InputType): any { + if (!value && value !== "0") { + // 빈 값 처리 + switch (inputType) { + case "number": + return 0; + case "checkbox": + return false; + default: + return ""; + } + } + + try { + switch (inputType) { + case "text": + case "select": + case "radio": + case "code": + case "entity": + return value; + + case "number": + const num = parseFloat(value); + return isNaN(num) ? 0 : num; + + case "date": + // YYYY-MM-DD 형식 그대로 반환 (HTML date input 호환) + return value; + + case "checkbox": + return value === "Y" || value === "true" || value === "1"; + + default: + return value; + } + } catch (error) { + logger.error(`Error converting value for display: ${error}`, { + value, + inputType, + }); + return value; + } + } + + /** + * 입력값 검증 + * 저장 전에 값이 해당 입력 타입에 적합한지 검증 + */ + static validate(value: any, inputType: InputType): ValidationResult { + // 빈 값은 일반적으로 허용 (필수 여부는 별도 검증) + if (!value && value !== 0 && value !== false) { + return { + isValid: true, + convertedValue: this.convertForStorage(value, inputType), + }; + } + + try { + switch (inputType) { + case "text": + case "select": + case "radio": + case "code": + case "entity": + const strValue = String(value).trim(); + if (strValue.length > 500) { + return { + isValid: false, + message: "입력값이 너무 깁니다. (최대 500자)", + }; + } + return { + isValid: true, + convertedValue: this.convertForStorage(value, inputType), + }; + + case "number": + const num = parseFloat(String(value)); + if (isNaN(num)) { + return { + isValid: false, + message: "숫자 형식이 올바르지 않습니다.", + }; + } + return { + isValid: true, + convertedValue: this.convertForStorage(value, inputType), + }; + + case "date": + if (!value) { + return { isValid: true, convertedValue: "" }; + } + const date = new Date(value); + if (isNaN(date.getTime())) { + return { + isValid: false, + message: "날짜 형식이 올바르지 않습니다.", + }; + } + return { + isValid: true, + convertedValue: this.convertForStorage(value, inputType), + }; + + case "checkbox": + // 체크박스는 모든 값을 허용 (Y/N으로 변환) + return { + isValid: true, + convertedValue: this.convertForStorage(value, inputType), + }; + + default: + return { + isValid: true, + convertedValue: this.convertForStorage(value, inputType), + }; + } + } catch (error) { + logger.error(`Error validating value: ${error}`, { value, inputType }); + return { + isValid: false, + message: "값 검증 중 오류가 발생했습니다.", + }; + } + } + + /** + * 배치 데이터 변환 (여러 필드를 한번에 처리) + */ + static convertBatchForStorage( + data: Record, + columnTypes: Record + ): Record { + const converted: Record = {}; + + for (const [columnName, value] of Object.entries(data)) { + const inputType = columnTypes[columnName]; + if (inputType) { + converted[columnName] = this.convertForStorage(value, inputType); + } else { + // 입력 타입이 정의되지 않은 경우 기본적으로 text로 처리 + converted[columnName] = this.convertForStorage(value, "text"); + } + } + + return converted; + } + + /** + * 배치 데이터 표시용 변환 + */ + static convertBatchForDisplay( + data: Record, + columnTypes: Record + ): Record { + const converted: Record = {}; + + for (const [columnName, value] of Object.entries(data)) { + const inputType = columnTypes[columnName]; + if (inputType) { + converted[columnName] = this.convertForDisplay(value, inputType); + } else { + // 입력 타입이 정의되지 않은 경우 문자열 그대로 반환 + converted[columnName] = value; + } + } + + return converted; + } + + /** + * 배치 데이터 검증 + */ + static validateBatch( + data: Record, + columnTypes: Record + ): { + isValid: boolean; + errors: Record; + convertedData: Record; + } { + const errors: Record = {}; + const convertedData: Record = {}; + + for (const [columnName, value] of Object.entries(data)) { + const inputType = columnTypes[columnName]; + if (inputType) { + const result = this.validate(value, inputType); + if (!result.isValid) { + errors[columnName] = result.message || "검증 실패"; + } else { + convertedData[columnName] = result.convertedValue || ""; + } + } else { + // 입력 타입이 정의되지 않은 경우 기본적으로 text로 처리 + convertedData[columnName] = this.convertForStorage(value, "text"); + } + } + + return { + isValid: Object.keys(errors).length === 0, + errors, + convertedData, + }; + } +} diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 7c0de736..3ae70d1f 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1013,23 +1013,52 @@ export class ScreenManagementService { * 데이터 타입으로부터 웹타입 추론 */ private inferWebType(dataType: string): WebType { + // 통합 타입 매핑에서 import + const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types"); + const lowerType = dataType.toLowerCase(); - if (lowerType.includes("char") || lowerType.includes("text")) { - return "text"; - } else if ( - lowerType.includes("int") || - lowerType.includes("numeric") || - lowerType.includes("decimal") - ) { + // 정확한 매핑 우선 확인 + if (DB_TYPE_TO_WEB_TYPE[lowerType]) { + return DB_TYPE_TO_WEB_TYPE[lowerType]; + } + + // 부분 문자열 매칭 (더 정교한 규칙) + for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) { + if ( + lowerType.includes(dbType.toLowerCase()) || + dbType.toLowerCase().includes(lowerType) + ) { + return webType as WebType; + } + } + + // 추가 정밀 매핑 + if (lowerType.includes("int") && !lowerType.includes("point")) { return "number"; - } else if (lowerType.includes("date") || lowerType.includes("time")) { + } else if (lowerType.includes("numeric") || lowerType.includes("decimal")) { + return "decimal"; + } else if ( + lowerType.includes("timestamp") || + lowerType.includes("datetime") + ) { + return "datetime"; + } else if (lowerType.includes("date")) { return "date"; + } else if (lowerType.includes("time")) { + return "datetime"; } else if (lowerType.includes("bool")) { return "checkbox"; - } else { - return "text"; + } else if ( + lowerType.includes("char") || + lowerType.includes("text") || + lowerType.includes("varchar") + ) { + return lowerType.includes("text") ? "textarea" : "text"; } + + // 기본값 + return "text"; } // ======================================== diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 2530d403..94f8aa30 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -10,6 +10,7 @@ import { EntityJoinResponse, EntityJoinConfig, } from "../types/tableManagement"; +import { WebType } from "../types/unified-web-types"; import { entityJoinService } from "./entityJoinService"; import { referenceCacheService } from "./referenceCacheService"; @@ -210,6 +211,11 @@ export class TableManagementService { : null, numericScale: column.numericScale ? Number(column.numericScale) : null, displayOrder: column.displayOrder ? Number(column.displayOrder) : null, + // 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론 + webType: + column.webType === "text" + ? this.inferWebType(column.dataType) + : column.webType, })); const totalPages = Math.ceil(total / size); @@ -323,7 +329,7 @@ export class TableManagementService { }, update: { column_label: settings.columnLabel, - web_type: settings.webType, + input_type: settings.inputType, detail_settings: settings.detailSettings, code_category: settings.codeCategory, code_value: settings.codeValue, @@ -339,7 +345,7 @@ export class TableManagementService { table_name: tableName, column_name: columnName, column_label: settings.columnLabel, - web_type: settings.webType, + input_type: settings.inputType, detail_settings: settings.detailSettings, code_category: settings.codeCategory, code_value: settings.codeValue, @@ -620,7 +626,123 @@ export class TableManagementService { } /** - * 웹 타입별 기본 상세 설정 생성 + * 컬럼 입력 타입 설정 (새로운 시스템) + */ + async updateColumnInputType( + tableName: string, + columnName: string, + inputType: string, + detailSettings?: Record + ): Promise { + try { + logger.info( + `컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}` + ); + + // 입력 타입별 기본 상세 설정 생성 + const defaultDetailSettings = + this.generateDefaultInputTypeSettings(inputType); + + // 사용자 정의 설정과 기본 설정 병합 + const finalDetailSettings = { + ...defaultDetailSettings, + ...detailSettings, + }; + + // table_type_columns 테이블에서 업데이트 + await prisma.$executeRaw` + INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date + ) VALUES ( + ${tableName}, ${columnName}, ${inputType}, ${JSON.stringify(finalDetailSettings)}, + 'Y', 0, now(), now() + ) + ON CONFLICT (table_name, column_name) + DO UPDATE SET + input_type = ${inputType}, + detail_settings = ${JSON.stringify(finalDetailSettings)}, + updated_date = now(); + `; + + logger.info( + `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}` + ); + } catch (error) { + logger.error( + `컬럼 입력 타입 설정 실패: ${tableName}.${columnName}`, + error + ); + throw new Error( + `컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 입력 타입별 기본 상세 설정 생성 + */ + private generateDefaultInputTypeSettings( + inputType: string + ): Record { + switch (inputType) { + case "text": + return { + maxLength: 500, + placeholder: "텍스트를 입력하세요", + }; + + case "number": + return { + min: 0, + step: 1, + placeholder: "숫자를 입력하세요", + }; + + case "date": + return { + format: "YYYY-MM-DD", + placeholder: "날짜를 선택하세요", + }; + + case "code": + return { + placeholder: "코드를 선택하세요", + searchable: true, + }; + + case "entity": + return { + placeholder: "항목을 선택하세요", + searchable: true, + }; + + case "select": + return { + placeholder: "선택하세요", + searchable: false, + }; + + case "checkbox": + return { + defaultChecked: false, + trueValue: "Y", + falseValue: "N", + }; + + case "radio": + return { + inline: false, + }; + + default: + return {}; + } + } + + /** + * 웹 타입별 기본 상세 설정 생성 (레거시 지원) + * @deprecated generateDefaultInputTypeSettings 사용 권장 */ private generateDefaultDetailSettings(webType: string): Record { switch (webType) { @@ -897,6 +1019,434 @@ export class TableManagementService { } } + /** + * 고급 검색 조건 구성 + */ + private async buildAdvancedSearchCondition( + tableName: string, + columnName: string, + value: any, + paramIndex: number + ): Promise<{ + whereClause: string; + values: any[]; + paramCount: number; + } | null> { + try { + // "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음 + if ( + value === "__ALL__" || + value === "" || + value === null || + value === undefined + ) { + return null; + } + + // 컬럼 타입 정보 조회 + const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); + + if (!columnInfo) { + // 컬럼 정보가 없으면 기본 문자열 검색 + return { + whereClause: `${columnName}::text ILIKE $${paramIndex}`, + values: [`%${value}%`], + paramCount: 1, + }; + } + + const webType = columnInfo.webType; + + // 웹타입별 검색 조건 구성 + switch (webType) { + case "date": + case "datetime": + return this.buildDateRangeCondition(columnName, value, paramIndex); + + case "number": + case "decimal": + return this.buildNumberRangeCondition(columnName, value, paramIndex); + + case "code": + return await this.buildCodeSearchCondition( + tableName, + columnName, + value, + paramIndex + ); + + case "entity": + return await this.buildEntitySearchCondition( + tableName, + columnName, + value, + paramIndex + ); + + default: + // 기본 문자열 검색 + return { + whereClause: `${columnName}::text ILIKE $${paramIndex}`, + values: [`%${value}%`], + paramCount: 1, + }; + } + } catch (error) { + logger.error( + `고급 검색 조건 구성 실패: ${tableName}.${columnName}`, + error + ); + // 오류 시 기본 검색으로 폴백 + return { + whereClause: `${columnName}::text ILIKE $${paramIndex}`, + values: [`%${value}%`], + paramCount: 1, + }; + } + } + + /** + * 날짜 범위 검색 조건 구성 + */ + private buildDateRangeCondition( + columnName: string, + value: any, + paramIndex: number + ): { + whereClause: string; + values: any[]; + paramCount: number; + } { + const conditions: string[] = []; + const values: any[] = []; + let paramCount = 0; + + if (typeof value === "object" && value !== null) { + if (value.from) { + conditions.push(`${columnName} >= $${paramIndex + paramCount}`); + values.push(value.from); + paramCount++; + } + if (value.to) { + conditions.push(`${columnName} <= $${paramIndex + paramCount}`); + values.push(value.to); + paramCount++; + } + } else if (typeof value === "string" && value.trim() !== "") { + // 단일 날짜 검색 (해당 날짜의 데이터) + conditions.push(`DATE(${columnName}) = DATE($${paramIndex})`); + values.push(value); + paramCount = 1; + } + + if (conditions.length === 0) { + return { + whereClause: `${columnName}::text ILIKE $${paramIndex}`, + values: [`%${value}%`], + paramCount: 1, + }; + } + + return { + whereClause: `(${conditions.join(" AND ")})`, + values, + paramCount, + }; + } + + /** + * 숫자 범위 검색 조건 구성 + */ + private buildNumberRangeCondition( + columnName: string, + value: any, + paramIndex: number + ): { + whereClause: string; + values: any[]; + paramCount: number; + } { + const conditions: string[] = []; + const values: any[] = []; + let paramCount = 0; + + if (typeof value === "object" && value !== null) { + if (value.min !== undefined && value.min !== null && value.min !== "") { + conditions.push( + `${columnName}::numeric >= $${paramIndex + paramCount}` + ); + values.push(parseFloat(value.min)); + paramCount++; + } + if (value.max !== undefined && value.max !== null && value.max !== "") { + conditions.push( + `${columnName}::numeric <= $${paramIndex + paramCount}` + ); + values.push(parseFloat(value.max)); + paramCount++; + } + } else if (typeof value === "string" || typeof value === "number") { + // 정확한 값 검색 + conditions.push(`${columnName}::numeric = $${paramIndex}`); + values.push(parseFloat(value.toString())); + paramCount = 1; + } + + if (conditions.length === 0) { + return { + whereClause: `${columnName}::text ILIKE $${paramIndex}`, + values: [`%${value}%`], + paramCount: 1, + }; + } + + return { + whereClause: `(${conditions.join(" AND ")})`, + values, + paramCount, + }; + } + + /** + * 코드 검색 조건 구성 + */ + private async buildCodeSearchCondition( + tableName: string, + columnName: string, + value: any, + paramIndex: number + ): Promise<{ + whereClause: string; + values: any[]; + paramCount: number; + }> { + try { + const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName); + + if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) { + // 코드 타입이 아니면 기본 검색 + return { + whereClause: `${columnName}::text ILIKE $${paramIndex}`, + values: [`%${value}%`], + paramCount: 1, + }; + } + + if (typeof value === "string" && value.trim() !== "") { + // 코드값 또는 코드명으로 검색 + return { + whereClause: `( + ${columnName}::text = $${paramIndex} OR + EXISTS ( + SELECT 1 FROM code_info ci + WHERE ci.code_category = $${paramIndex + 1} + AND ci.code_value = ${columnName} + AND ci.code_name ILIKE $${paramIndex + 2} + ) + )`, + values: [value, codeTypeInfo.codeCategory, `%${value}%`], + paramCount: 3, + }; + } else { + // 정확한 코드값 매칭 + return { + whereClause: `${columnName} = $${paramIndex}`, + values: [value], + paramCount: 1, + }; + } + } catch (error) { + logger.error( + `코드 검색 조건 구성 실패: ${tableName}.${columnName}`, + error + ); + return { + whereClause: `${columnName}::text ILIKE $${paramIndex}`, + values: [`%${value}%`], + paramCount: 1, + }; + } + } + + /** + * 엔티티 검색 조건 구성 + */ + private async buildEntitySearchCondition( + tableName: string, + columnName: string, + value: any, + paramIndex: number + ): Promise<{ + whereClause: string; + values: any[]; + paramCount: number; + }> { + try { + const entityTypeInfo = await this.getEntityTypeInfo( + tableName, + columnName + ); + + if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) { + // 엔티티 타입이 아니면 기본 검색 + return { + whereClause: `${columnName}::text ILIKE $${paramIndex}`, + values: [`%${value}%`], + paramCount: 1, + }; + } + + if (typeof value === "string" && value.trim() !== "") { + const displayColumn = entityTypeInfo.displayColumn || "name"; + const referenceColumn = entityTypeInfo.referenceColumn || "id"; + + // 참조 테이블의 표시 컬럼으로 검색 + return { + whereClause: `EXISTS ( + SELECT 1 FROM ${entityTypeInfo.referenceTable} ref + WHERE ref.${referenceColumn} = ${columnName} + AND ref.${displayColumn} ILIKE $${paramIndex} + )`, + values: [`%${value}%`], + paramCount: 1, + }; + } else { + // 정확한 참조값 매칭 + return { + whereClause: `${columnName} = $${paramIndex}`, + values: [value], + paramCount: 1, + }; + } + } catch (error) { + logger.error( + `엔티티 검색 조건 구성 실패: ${tableName}.${columnName}`, + error + ); + return { + whereClause: `${columnName}::text ILIKE $${paramIndex}`, + values: [`%${value}%`], + paramCount: 1, + }; + } + } + + /** + * 불린 검색 조건 구성 + */ + private buildBooleanCondition( + columnName: string, + value: any, + paramIndex: number + ): { + whereClause: string; + values: any[]; + paramCount: number; + } { + if (value === "true" || value === true) { + return { + whereClause: `${columnName} = true`, + values: [], + paramCount: 0, + }; + } else if (value === "false" || value === false) { + return { + whereClause: `${columnName} = false`, + values: [], + paramCount: 0, + }; + } else { + // 기본 검색 + return { + whereClause: `${columnName}::text ILIKE $${paramIndex}`, + values: [`%${value}%`], + paramCount: 1, + }; + } + } + + /** + * 컬럼 웹타입 정보 조회 + */ + private async getColumnWebTypeInfo( + tableName: string, + columnName: string + ): Promise<{ + webType: string; + codeCategory?: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + } | null> { + try { + const result = await prisma.column_labels.findFirst({ + where: { + table_name: tableName, + column_name: columnName, + }, + select: { + web_type: true, + code_category: true, + reference_table: true, + reference_column: true, + display_column: true, + }, + }); + + if (!result) { + return null; + } + + return { + webType: result.web_type || "", + codeCategory: result.code_category || undefined, + referenceTable: result.reference_table || undefined, + referenceColumn: result.reference_column || undefined, + displayColumn: result.display_column || undefined, + }; + } catch (error) { + logger.error( + `컬럼 웹타입 정보 조회 실패: ${tableName}.${columnName}`, + error + ); + return null; + } + } + + /** + * 엔티티 타입 정보 조회 + */ + private async getEntityTypeInfo( + tableName: string, + columnName: string + ): Promise<{ + isEntityType: boolean; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + }> { + try { + const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); + + if (!columnInfo || columnInfo.webType !== "entity") { + return { isEntityType: false }; + } + + return { + isEntityType: true, + referenceTable: columnInfo.referenceTable, + referenceColumn: columnInfo.referenceColumn, + displayColumn: columnInfo.displayColumn, + }; + } catch (error) { + logger.error( + `엔티티 타입 정보 조회 실패: ${tableName}.${columnName}`, + error + ); + return { isEntityType: false }; + } + } + /** * 테이블 데이터 조회 (페이징 + 검색) */ @@ -949,42 +1499,19 @@ export class TableManagementService { // 안전한 컬럼명 검증 (SQL 인젝션 방지) const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, ""); - if (typeof value === "string") { - // 🎯 코드 타입 컬럼의 경우 코드값과 코드명 모두로 검색 - const codeTypeInfo = await this.getCodeTypeInfo( - tableName, - safeColumn - ); + // 🎯 고급 필터 처리 + const condition = await this.buildAdvancedSearchCondition( + tableName, + safeColumn, + value, + paramIndex + ); - if (codeTypeInfo.isCodeType && codeTypeInfo.codeCategory) { - // 코드 타입 컬럼: 코드값 또는 코드명으로 검색 - // 1) 컬럼 값이 직접 검색어와 일치하는 경우 - // 2) 컬럼 값이 코드값이고, 해당 코드의 코드명이 검색어와 일치하는 경우 - whereConditions.push(`( - ${safeColumn}::text ILIKE $${paramIndex} OR - EXISTS ( - SELECT 1 FROM code_info ci - WHERE ci.code_category = $${paramIndex + 1} - AND ci.code_value = ${safeColumn} - AND ci.code_name ILIKE $${paramIndex + 2} - ) - )`); - searchValues.push(`%${value}%`); // 직접 값 검색용 - searchValues.push(codeTypeInfo.codeCategory); // 코드 카테고리 - searchValues.push(`%${value}%`); // 코드명 검색용 - paramIndex += 2; // 추가 파라미터로 인해 인덱스 증가 - } else { - // 일반 컬럼: 기존 방식 - whereConditions.push( - `${safeColumn}::text ILIKE $${paramIndex}` - ); - searchValues.push(`%${value}%`); - } - } else { - whereConditions.push(`${safeColumn} = $${paramIndex}`); - searchValues.push(value); + if (condition) { + whereConditions.push(condition.whereClause); + searchValues.push(...condition.values); + paramIndex += condition.paramCount; } - paramIndex++; } } } @@ -1576,7 +2103,10 @@ export class TableManagementService { const selectColumns = columns.data.map((col: any) => col.column_name); // WHERE 절 구성 - const whereClause = this.buildWhereClause(options.search); + const whereClause = await this.buildWhereClause( + tableName, + options.search + ); // ORDER BY 절 구성 const orderBy = options.sortBy @@ -1939,21 +2469,77 @@ export class TableManagementService { } /** - * WHERE 절 구성 + * WHERE 절 구성 (고급 검색 지원) */ - private buildWhereClause(search?: Record): string { + private async buildWhereClause( + tableName: string, + search?: Record + ): Promise { if (!search || Object.keys(search).length === 0) { return ""; } const conditions: string[] = []; - for (const [key, value] of Object.entries(search)) { - if (value !== undefined && value !== null && value !== "") { + for (const [columnName, value] of Object.entries(search)) { + if ( + value === undefined || + value === null || + value === "" || + value === "__ALL__" + ) { + continue; + } + + try { + // 고급 검색 조건 구성 + const searchCondition = await this.buildAdvancedSearchCondition( + tableName, + columnName, + value, + 1 // paramIndex는 실제로는 사용되지 않음 (직접 값 삽입) + ); + + if (searchCondition) { + // SQL 인젝션 방지를 위해 값을 직접 삽입하는 대신 안전한 방식 사용 + let condition = searchCondition.whereClause; + + // 파라미터를 실제 값으로 치환 (안전한 방식) + searchCondition.values.forEach((val, index) => { + const paramPlaceholder = `$${index + 1}`; + if (typeof val === "string") { + condition = condition.replace( + paramPlaceholder, + `'${val.replace(/'/g, "''")}'` + ); + } else if (typeof val === "number") { + condition = condition.replace(paramPlaceholder, val.toString()); + } else { + condition = condition.replace( + paramPlaceholder, + `'${String(val).replace(/'/g, "''")}'` + ); + } + }); + + // main. 접두사 추가 (조인 쿼리용) + condition = condition.replace( + new RegExp(`\\b${columnName}\\b`, "g"), + `main.${columnName}` + ); + conditions.push(condition); + } + } catch (error) { + logger.warn(`검색 조건 구성 실패: ${columnName}`, error); + // 폴백: 기본 문자열 검색 if (typeof value === "string") { - conditions.push(`main.${key} ILIKE '%${value}%'`); + conditions.push( + `main.${columnName}::text ILIKE '%${value.replace(/'/g, "''")}%'` + ); } else { - conditions.push(`main.${key} = '${value}'`); + conditions.push( + `main.${columnName} = '${String(value).replace(/'/g, "''")}'` + ); } } } @@ -2267,4 +2853,237 @@ export class TableManagementService { return totalHitRate / cacheableJoins.length; } + + /** + * 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용) + */ + async getTableSchema(tableName: string): Promise { + try { + logger.info(`테이블 스키마 정보 조회: ${tableName}`); + + const rawColumns = await prisma.$queryRaw` + SELECT + column_name as "columnName", + column_name as "displayName", + data_type as "dataType", + udt_name as "dbType", + is_nullable as "isNullable", + column_default as "defaultValue", + character_maximum_length as "maxLength", + numeric_precision as "numericPrecision", + numeric_scale as "numericScale", + CASE + WHEN column_name IN ( + SELECT column_name FROM information_schema.key_column_usage + WHERE table_name = ${tableName} AND constraint_name LIKE '%_pkey' + ) THEN true + ELSE false + END as "isPrimaryKey" + FROM information_schema.columns + WHERE table_name = ${tableName} + AND table_schema = 'public' + ORDER BY ordinal_position + `; + + const columns: ColumnTypeInfo[] = rawColumns.map((col) => ({ + tableName: tableName, + columnName: col.columnName, + displayName: col.displayName, + dataType: col.dataType, + dbType: col.dbType, + webType: "text", // 기본값 + inputType: "direct", + detailSettings: "{}", + description: "", // 필수 필드 추가 + isNullable: col.isNullable, + isPrimaryKey: col.isPrimaryKey, + defaultValue: col.defaultValue, + maxLength: col.maxLength ? Number(col.maxLength) : undefined, + numericPrecision: col.numericPrecision + ? Number(col.numericPrecision) + : undefined, + numericScale: col.numericScale ? Number(col.numericScale) : undefined, + displayOrder: 0, + isVisible: true, + })); + + logger.info( + `테이블 스키마 조회 완료: ${tableName}, ${columns.length}개 컬럼` + ); + return columns; + } catch (error) { + logger.error(`테이블 스키마 조회 실패: ${tableName}`, error); + throw error; + } + } + + /** + * 테이블 존재 여부 확인 + */ + async checkTableExists(tableName: string): Promise { + try { + logger.info(`테이블 존재 여부 확인: ${tableName}`); + + const result = await prisma.$queryRaw` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = ${tableName} + AND table_schema = 'public' + AND table_type = 'BASE TABLE' + ) as "exists" + `; + + const exists = result[0]?.exists || false; + logger.info(`테이블 존재 여부: ${tableName} = ${exists}`); + return exists; + } catch (error) { + logger.error(`테이블 존재 여부 확인 실패: ${tableName}`, error); + throw error; + } + } + + /** + * 컬럼 입력타입 정보 조회 (화면관리 연동용) + */ + async getColumnInputTypes(tableName: string): Promise { + try { + logger.info(`컬럼 입력타입 정보 조회: ${tableName}`); + + // table_type_columns에서 입력타입 정보 조회 + const rawInputTypes = await prisma.$queryRaw` + SELECT + ttc.column_name as "columnName", + ttc.column_name as "displayName", + COALESCE(ttc.input_type, 'text') as "inputType", + COALESCE(ttc.detail_settings, '{}') as "detailSettings", + ttc.is_nullable as "isNullable", + ic.data_type as "dataType" + FROM table_type_columns ttc + LEFT JOIN information_schema.columns ic + ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name + WHERE ttc.table_name = ${tableName} + ORDER BY ttc.display_order, ttc.column_name + `; + + const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({ + tableName: tableName, + columnName: col.columnName, + displayName: col.displayName, + dataType: col.dataType || "varchar", + inputType: col.inputType, + detailSettings: col.detailSettings, + description: "", // 필수 필드 추가 + isNullable: col.isNullable, + isPrimaryKey: false, + displayOrder: 0, + isVisible: true, + })); + + logger.info( + `컬럼 입력타입 정보 조회 완료: ${tableName}, ${inputTypes.length}개 컬럼` + ); + return inputTypes; + } catch (error) { + logger.error(`컬럼 입력타입 정보 조회 실패: ${tableName}`, error); + throw error; + } + } + + /** + * 레거시 지원: 컬럼 웹타입 정보 조회 + * @deprecated getColumnInputTypes 사용 권장 + */ + async getColumnWebTypes(tableName: string): Promise { + logger.warn( + `레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장` + ); + return this.getColumnInputTypes(tableName); + } + + /** + * 데이터베이스 연결 상태 확인 + */ + async checkDatabaseConnection(): Promise<{ + connected: boolean; + message: string; + }> { + try { + logger.info("데이터베이스 연결 상태 확인"); + + // 간단한 쿼리로 연결 테스트 + const result = await prisma.$queryRaw`SELECT 1 as "test"`; + + if (result && result.length > 0) { + logger.info("데이터베이스 연결 성공"); + return { + connected: true, + message: "데이터베이스에 성공적으로 연결되었습니다.", + }; + } else { + logger.warn("데이터베이스 연결 응답 없음"); + return { + connected: false, + message: "데이터베이스 연결 응답이 없습니다.", + }; + } + } catch (error) { + logger.error("데이터베이스 연결 확인 실패:", error); + return { + connected: false, + message: `데이터베이스 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`, + }; + } + } + + /** + * 데이터 타입으로부터 웹타입 추론 + */ + private inferWebType(dataType: string): WebType { + // 통합 타입 매핑에서 import + const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types"); + + const lowerType = dataType.toLowerCase(); + + // 정확한 매핑 우선 확인 + if (DB_TYPE_TO_WEB_TYPE[lowerType]) { + return DB_TYPE_TO_WEB_TYPE[lowerType]; + } + + // 부분 문자열 매칭 (더 정교한 규칙) + for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) { + if ( + lowerType.includes(dbType.toLowerCase()) || + dbType.toLowerCase().includes(lowerType) + ) { + return webType as WebType; + } + } + + // 추가 정밀 매핑 + if (lowerType.includes("int") && !lowerType.includes("point")) { + return "number"; + } else if (lowerType.includes("numeric") || lowerType.includes("decimal")) { + return "decimal"; + } else if ( + lowerType.includes("timestamp") || + lowerType.includes("datetime") + ) { + return "datetime"; + } else if (lowerType.includes("date")) { + return "date"; + } else if (lowerType.includes("time")) { + return "datetime"; + } else if (lowerType.includes("bool")) { + return "checkbox"; + } else if ( + lowerType.includes("char") || + lowerType.includes("text") || + lowerType.includes("varchar") + ) { + return lowerType.includes("text") ? "textarea" : "text"; + } + + // 기본값 + return "text"; + } } diff --git a/backend-node/src/types/ddl.ts b/backend-node/src/types/ddl.ts new file mode 100644 index 00000000..cb6b79dc --- /dev/null +++ b/backend-node/src/types/ddl.ts @@ -0,0 +1,316 @@ +/** + * 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; + /** 입력타입 */ + inputType?: string; + /** 웹타입 (레거시 호환용) */ + webType?: WebType; + /** NULL 허용 여부 */ + nullable?: boolean; + /** 컬럼 길이 (text, code 타입에서 사용) */ + length?: number; + /** 기본값 */ + defaultValue?: string; + /** 컬럼 설명 */ + description?: string; + /** 표시 순서 */ + order?: number; + /** 상세 설정 (JSON 형태) */ + detailSettings?: Record; +} + +// 테이블 생성 요청 인터페이스 +export interface CreateTableRequest { + /** 테이블명 */ + tableName: string; + /** 테이블 설명 */ + description?: string; + /** 컬럼 정의 목록 */ + columns: CreateColumnDefinition[]; +} + +// 컬럼 추가 요청 인터페이스 +export interface AddColumnRequest { + /** 컬럼 정의 */ + column: CreateColumnDefinition; +} + +// DDL 실행 결과 인터페이스 +export interface DDLExecutionResult { + /** 실행 성공 여부 */ + success: boolean; + /** 결과 메시지 */ + message: string; + /** 실행된 DDL 쿼리 */ + executedQuery?: string; + /** 오류 정보 */ + error?: { + code: string; + details: string; + }; +} + +// 검증 결과 인터페이스 +export interface ValidationResult { + /** 검증 통과 여부 */ + isValid: boolean; + /** 오류 메시지 목록 */ + errors: string[]; + /** 경고 메시지 목록 */ + warnings?: string[]; +} + +// DDL 실행 로그 인터페이스 +export interface DDLExecutionLog { + /** 로그 ID */ + id: number; + /** 사용자 ID */ + user_id: string; + /** 회사 코드 */ + company_code: string; + /** DDL 유형 */ + ddl_type: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN"; + /** 테이블명 */ + table_name: string; + /** 실행된 DDL 쿼리 */ + ddl_query: string; + /** 실행 성공 여부 */ + success: boolean; + /** 오류 메시지 (실패 시) */ + error_message?: string; + /** 실행 시간 */ + executed_at: Date; +} + +// PostgreSQL 타입 매핑 +export interface PostgreSQLTypeMapping { + /** 웹타입 */ + webType: WebType; + /** PostgreSQL 데이터 타입 */ + postgresType: string; + /** 기본 길이 (있는 경우) */ + defaultLength?: number; + /** 길이 지정 가능 여부 */ + supportsLength: boolean; +} + +// 테이블 메타데이터 인터페이스 +export interface TableMetadata { + /** 테이블명 */ + tableName: string; + /** 테이블 라벨 */ + tableLabel: string; + /** 테이블 설명 */ + description?: string; + /** 생성 일시 */ + createdDate: Date; + /** 수정 일시 */ + updatedDate: Date; +} + +// 컬럼 메타데이터 인터페이스 +export interface ColumnMetadata { + /** 테이블명 */ + tableName: string; + /** 컬럼명 */ + columnName: string; + /** 컬럼 라벨 */ + columnLabel: string; + /** 웹타입 */ + webType: WebType; + /** 상세 설정 (JSON 문자열) */ + detailSettings: string; + /** 컬럼 설명 */ + description?: string; + /** 표시 순서 */ + displayOrder: number; + /** 표시 여부 */ + isVisible: boolean; + /** 코드 카테고리 (code 타입용) */ + codeCategory?: string; + /** 코드 값 (code 타입용) */ + codeValue?: string; + /** 참조 테이블 (entity 타입용) */ + referenceTable?: string; + /** 참조 컬럼 (entity 타입용) */ + referenceColumn?: string; + /** 생성 일시 */ + createdDate: Date; + /** 수정 일시 */ + updatedDate: Date; +} + +// 시스템 테이블 목록 (보호 대상) +export const SYSTEM_TABLES = [ + "user_info", + "company_mng", + "menu_info", + "auth_group", + "table_labels", + "column_labels", + "screen_definitions", + "screen_layouts", + "common_code", + "multi_lang_key_master", + "multi_lang_text", + "button_action_standards", + "ddl_execution_log", +] as const; + +// 예약어 목록 +export const RESERVED_WORDS = [ + "user", + "order", + "group", + "table", + "column", + "index", + "select", + "insert", + "update", + "delete", + "from", + "where", + "join", + "on", + "as", + "and", + "or", + "not", + "null", + "true", + "false", + "create", + "alter", + "drop", + "primary", + "key", + "foreign", + "references", + "constraint", + "default", + "unique", + "check", + "view", + "procedure", + "function", +] as const; + +// 예약된 컬럼명 목록 (자동 추가되는 기본 컬럼들) +export const RESERVED_COLUMNS = [ + "id", + "created_date", + "updated_date", + "company_code", +] as const; + +// 웹타입별 PostgreSQL 타입 매핑 +export const WEB_TYPE_TO_POSTGRES_MAP: Record = + { + text: { + webType: "text", + postgresType: "varchar", + defaultLength: 255, + supportsLength: true, + }, + number: { + webType: "number", + postgresType: "integer", + supportsLength: false, + }, + decimal: { + webType: "decimal", + postgresType: "numeric(10,2)", + supportsLength: false, + }, + date: { + webType: "date", + postgresType: "date", + supportsLength: false, + }, + datetime: { + webType: "datetime", + postgresType: "timestamp", + supportsLength: false, + }, + boolean: { + webType: "boolean", + postgresType: "boolean", + supportsLength: false, + }, + code: { + webType: "code", + postgresType: "varchar", + defaultLength: 100, + supportsLength: true, + }, + entity: { + webType: "entity", + postgresType: "integer", + supportsLength: false, + }, + textarea: { + webType: "textarea", + postgresType: "text", + supportsLength: false, + }, + select: { + webType: "select", + postgresType: "varchar", + defaultLength: 100, + supportsLength: true, + }, + checkbox: { + webType: "checkbox", + postgresType: "boolean", + supportsLength: false, + }, + radio: { + webType: "radio", + postgresType: "varchar", + defaultLength: 100, + supportsLength: true, + }, + file: { + webType: "file", + postgresType: "text", + supportsLength: false, + }, + email: { + webType: "email", + postgresType: "varchar", + defaultLength: 255, + supportsLength: true, + }, + tel: { + webType: "tel", + postgresType: "varchar", + defaultLength: 50, + supportsLength: true, + }, + }; diff --git a/backend-node/src/types/input-types.ts b/backend-node/src/types/input-types.ts new file mode 100644 index 00000000..98e429c1 --- /dev/null +++ b/backend-node/src/types/input-types.ts @@ -0,0 +1,115 @@ +/** + * 백엔드 입력 타입 정의 (테이블 타입 관리 개선) + * 프론트엔드와 동일한 8개 핵심 입력 타입 정의 + */ + +// 8개 핵심 입력 타입 (프론트엔드와 동일) +export type InputType = + | "text" // 텍스트 + | "number" // 숫자 + | "date" // 날짜 + | "code" // 코드 + | "entity" // 엔티티 + | "select" // 선택박스 + | "checkbox" // 체크박스 + | "radio"; // 라디오버튼 + +// 입력 타입 옵션 정의 +export interface InputTypeOption { + value: InputType; + label: string; + description: string; + category: "basic" | "reference" | "selection"; +} + +// 입력 타입 옵션 목록 +export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [ + { + value: "text", + label: "텍스트", + description: "일반 텍스트 입력", + category: "basic", + }, + { + value: "number", + label: "숫자", + description: "숫자 입력 (정수/소수)", + category: "basic", + }, + { value: "date", label: "날짜", description: "날짜 선택", category: "basic" }, + { + value: "code", + label: "코드", + description: "공통코드 참조", + category: "reference", + }, + { + value: "entity", + label: "엔티티", + description: "다른 테이블 참조", + category: "reference", + }, + { + value: "select", + label: "선택박스", + description: "드롭다운 선택", + category: "selection", + }, + { + value: "checkbox", + label: "체크박스", + description: "체크박스 입력", + category: "selection", + }, + { + value: "radio", + label: "라디오버튼", + description: "단일 선택", + category: "selection", + }, +]; + +// 입력 타입 검증 함수 +export const isValidInputType = (inputType: string): inputType is InputType => { + return INPUT_TYPE_OPTIONS.some((option) => option.value === inputType); +}; + +// 레거시 웹 타입 → 입력 타입 매핑 +export const WEB_TYPE_TO_INPUT_TYPE: Record = { + // 텍스트 관련 + text: "text", + textarea: "text", + email: "text", + tel: "text", + url: "text", + password: "text", + + // 숫자 관련 + number: "number", + decimal: "number", + + // 날짜 관련 + date: "date", + datetime: "date", + time: "date", + + // 선택 관련 + select: "select", + dropdown: "select", + checkbox: "checkbox", + boolean: "checkbox", + radio: "radio", + + // 참조 관련 + code: "code", + entity: "entity", + + // 기타 (기본값: text) + file: "text", + button: "text", +}; + +// 입력 타입 변환 함수 +export const convertWebTypeToInputType = (webType: string): InputType => { + return WEB_TYPE_TO_INPUT_TYPE[webType] || "text"; +}; diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts index 6c83c0ee..d774cee4 100644 --- a/backend-node/src/types/screen.ts +++ b/backend-node/src/types/screen.ts @@ -4,17 +4,8 @@ export type ComponentType = "container" | "row" | "column" | "widget" | "group"; // 웹 타입 정의 -export type WebType = - | "text" - | "number" - | "date" - | "code" - | "entity" - | "textarea" - | "select" - | "checkbox" - | "radio" - | "file"; +// WebType은 통합 타입에서 import (중복 정의 제거) +export { WebType } from "./unified-web-types"; // 위치 정보 export interface Position { diff --git a/backend-node/src/types/tableManagement.ts b/backend-node/src/types/tableManagement.ts index dec3ab16..52dca092 100644 --- a/backend-node/src/types/tableManagement.ts +++ b/backend-node/src/types/tableManagement.ts @@ -8,16 +8,15 @@ export interface TableInfo { } export interface ColumnTypeInfo { + tableName?: string; columnName: string; displayName: string; - dataType: string; // 추가: 데이터 타입 (dbType과 동일하지만 별도 필드) - dbType: string; - webType: string; - inputType?: "direct" | "auto"; + dataType: string; // DB 데이터 타입 (varchar, integer 등) + inputType: string; // 입력 타입 (text, number, date, code, entity, select, checkbox, radio) detailSettings: string; description: string; isNullable: string; - isPrimaryKey: boolean; // 추가: 기본키 여부 + isPrimaryKey: boolean; defaultValue?: string; maxLength?: number; numericPrecision?: number; @@ -34,7 +33,7 @@ export interface ColumnTypeInfo { export interface ColumnSettings { columnName?: string; // 컬럼명 (업데이트 시 필요) columnLabel: string; // 컬럼 표시명 - webType: string; // 웹 입력 타입 (text, number, date, code, entity) + inputType: string; // 입력 타입 (text, number, date, code, entity, select, checkbox, radio) detailSettings: string; // 상세 설정 codeCategory: string; // 코드 카테고리 codeValue: string; // 코드 값 diff --git a/backend-node/src/types/unified-web-types.ts b/backend-node/src/types/unified-web-types.ts new file mode 100644 index 00000000..52f953ac --- /dev/null +++ b/backend-node/src/types/unified-web-types.ts @@ -0,0 +1,406 @@ +/** + * 백엔드 통합 웹 타입 정의 + * 프론트엔드와 동일한 웹 타입 정의 유지 + */ + +// 기본 웹 타입 (프론트엔드와 동일) +export type BaseWebType = + | "text" // 일반 텍스트 + | "number" // 숫자 (정수) + | "decimal" // 소수점 숫자 + | "date" // 날짜 + | "datetime" // 날짜시간 + | "time" // 시간 + | "textarea" // 여러줄 텍스트 + | "select" // 선택박스 + | "dropdown" // 드롭다운 (select와 동일) + | "checkbox" // 체크박스 + | "radio" // 라디오버튼 + | "boolean" // 불린값 + | "file" // 파일 업로드 + | "email" // 이메일 + | "tel" // 전화번호 + | "url" // URL + | "password" // 패스워드 + | "code" // 공통코드 참조 + | "entity" // 엔티티 참조 + | "button"; // 버튼 + +// 레거시 지원용 +export type LegacyWebType = "text_area"; // textarea와 동일 + +// 전체 웹 타입 +export type WebType = BaseWebType | LegacyWebType; + +// 동적 웹 타입 (런타임에 DB에서 로드되는 타입 포함) +export type DynamicWebType = WebType | string; + +// 웹 타입 매핑 (레거시 지원) +export const WEB_TYPE_MAPPINGS: Record = { + text_area: "textarea", +}; + +// 웹 타입 정규화 함수 +export const normalizeWebType = (webType: DynamicWebType): WebType => { + if (webType in WEB_TYPE_MAPPINGS) { + return WEB_TYPE_MAPPINGS[webType as LegacyWebType]; + } + return webType as WebType; +}; + +// 웹 타입 검증 함수 +export const isValidWebType = (webType: string): webType is WebType => { + return ( + [ + "text", + "number", + "decimal", + "date", + "datetime", + "time", + "textarea", + "select", + "dropdown", + "checkbox", + "radio", + "boolean", + "file", + "email", + "tel", + "url", + "password", + "code", + "entity", + "button", + "text_area", // 레거시 지원 + ] as string[] + ).includes(webType); +}; + +// DB 타입과 웹 타입 매핑 +export const DB_TYPE_TO_WEB_TYPE: Record = { + // 텍스트 타입 + "character varying": "text", + varchar: "text", + text: "textarea", + char: "text", + + // 숫자 타입 + integer: "number", + bigint: "number", + smallint: "number", + serial: "number", + bigserial: "number", + numeric: "decimal", + decimal: "decimal", + real: "decimal", + "double precision": "decimal", + + // 날짜/시간 타입 + date: "date", + timestamp: "datetime", + "timestamp with time zone": "datetime", + "timestamp without time zone": "datetime", + time: "time", + "time with time zone": "time", + "time without time zone": "time", + + // 불린 타입 + boolean: "boolean", + + // JSON 타입 (텍스트로 처리) + json: "textarea", + jsonb: "textarea", + + // 배열 타입 (텍스트로 처리) + ARRAY: "textarea", + + // UUID 타입 + uuid: "text", +}; + +// 웹 타입별 PostgreSQL 타입 변환 규칙 +export const WEB_TYPE_TO_POSTGRES_CONVERTER: Record< + WebType, + (value: any) => any +> = { + text: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + number: (value) => { + if (value === null || value === undefined || value === "") return null; + const num = parseInt(String(value)); + return isNaN(num) ? null : num; + }, + + decimal: (value) => { + if (value === null || value === undefined || value === "") return null; + const num = parseFloat(String(value)); + return isNaN(num) ? null : num; + }, + + date: (value) => { + if (value === null || value === undefined || value === "") return null; + const date = new Date(value); + return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0]; + }, + + datetime: (value) => { + if (value === null || value === undefined || value === "") return null; + const date = new Date(value); + return isNaN(date.getTime()) ? null : date.toISOString(); + }, + + time: (value) => { + if (value === null || value === undefined || value === "") return null; + // 시간 형식 처리 (HH:mm:ss) + return String(value); + }, + + textarea: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + select: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + dropdown: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + checkbox: (value) => { + if (value === null || value === undefined) return false; + if (typeof value === "boolean") return value; + if (typeof value === "string") { + return value.toLowerCase() === "true" || value === "1" || value === "Y"; + } + return Boolean(value); + }, + + radio: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + boolean: (value) => { + if (value === null || value === undefined) return null; + if (typeof value === "boolean") return value; + if (typeof value === "string") { + return value.toLowerCase() === "true" || value === "1" || value === "Y"; + } + return Boolean(value); + }, + + file: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + email: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + tel: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + url: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + password: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + code: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + entity: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), + + button: (value) => null, // 버튼은 저장하지 않음 + + // 레거시 지원 + text_area: (value) => + value === null || value === undefined || value === "" + ? null + : String(value), +}; + +// 웹 타입별 검증 규칙 +export const WEB_TYPE_VALIDATION_PATTERNS: Record = { + text: null, + number: /^-?\d+$/, + decimal: /^-?\d+(\.\d+)?$/, + date: /^\d{4}-\d{2}-\d{2}$/, + datetime: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/, + time: /^\d{2}:\d{2}(:\d{2})?$/, + textarea: null, + select: null, + dropdown: null, + checkbox: null, + radio: null, + boolean: null, + file: null, + email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + tel: /^(\d{2,3}-?\d{3,4}-?\d{4}|\d{10,11})$/, + url: /^https?:\/\/.+/, + password: null, + code: null, + entity: null, + button: null, + text_area: null, // 레거시 지원 +}; + +// 업데이트된 웹 타입 옵션 (기존 WEB_TYPE_OPTIONS 대체) +export const UNIFIED_WEB_TYPE_OPTIONS = [ + { + value: "text", + label: "text", + description: "일반 텍스트 입력", + category: "input", + }, + { + value: "number", + label: "number", + description: "숫자 입력 (정수)", + category: "input", + }, + { + value: "decimal", + label: "decimal", + description: "소수점 숫자 입력", + category: "input", + }, + { + value: "date", + label: "date", + description: "날짜 선택기", + category: "input", + }, + { + value: "datetime", + label: "datetime", + description: "날짜시간 선택기", + category: "input", + }, + { + value: "time", + label: "time", + description: "시간 선택기", + category: "input", + }, + { + value: "textarea", + label: "textarea", + description: "여러 줄 텍스트", + category: "input", + }, + { + value: "select", + label: "select", + description: "선택박스", + category: "selection", + }, + { + value: "dropdown", + label: "dropdown", + description: "드롭다운", + category: "selection", + }, + { + value: "checkbox", + label: "checkbox", + description: "체크박스", + category: "selection", + }, + { + value: "radio", + label: "radio", + description: "라디오 버튼", + category: "selection", + }, + { + value: "boolean", + label: "boolean", + description: "불린값 (예/아니오)", + category: "selection", + }, + { + value: "file", + label: "file", + description: "파일 업로드", + category: "upload", + }, + { + value: "email", + label: "email", + description: "이메일 주소", + category: "input", + }, + { value: "tel", label: "tel", description: "전화번호", category: "input" }, + { + value: "url", + label: "url", + description: "웹사이트 주소", + category: "input", + }, + { + value: "password", + label: "password", + description: "비밀번호", + category: "input", + }, + { + value: "code", + label: "code", + description: "코드 선택 (공통코드)", + category: "reference", + }, + { + value: "entity", + label: "entity", + description: "엔티티 참조 (참조테이블)", + category: "reference", + }, + { value: "button", label: "button", description: "버튼", category: "action" }, +] as const; + +// 웹 타입별 기본 설정 +export const WEB_TYPE_DEFAULT_CONFIGS: Record> = { + text: { maxLength: 255, placeholder: "텍스트를 입력하세요" }, + number: { min: 0, max: 2147483647, step: 1 }, + decimal: { min: 0, step: 0.01, decimalPlaces: 2 }, + date: { format: "YYYY-MM-DD" }, + datetime: { format: "YYYY-MM-DD HH:mm:ss", showTime: true }, + time: { format: "HH:mm:ss" }, + textarea: { rows: 4, cols: 50, maxLength: 1000 }, + select: { placeholder: "선택하세요", searchable: false }, + dropdown: { placeholder: "선택하세요", searchable: true }, + checkbox: { defaultChecked: false }, + radio: { inline: false }, + boolean: { trueValue: true, falseValue: false }, + file: { multiple: false, preview: true }, + email: { placeholder: "이메일을 입력하세요" }, + tel: { placeholder: "전화번호를 입력하세요" }, + url: { placeholder: "URL을 입력하세요" }, + password: { placeholder: "비밀번호를 입력하세요" }, + code: { placeholder: "코드를 선택하세요", searchable: true }, + entity: { placeholder: "항목을 선택하세요", searchable: true }, + button: { variant: "default" }, + text_area: { rows: 4, cols: 50, maxLength: 1000 }, // 레거시 지원 +}; diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx index e4bec481..b320ab45 100644 --- a/frontend/app/(main)/admin/page.tsx +++ b/frontend/app/(main)/admin/page.tsx @@ -5,13 +5,13 @@ import Link from "next/link"; */ export default function AdminPage() { return ( -
+
{/* 관리자 기능 카드들 */} -
+
-
+
@@ -24,8 +24,8 @@ export default function AdminPage() {
-
- +
+

권한 관리

@@ -36,8 +36,8 @@ export default function AdminPage() {
-
- +
+

시스템 설정

@@ -48,8 +48,8 @@ export default function AdminPage() {
-
- +
+

통계 및 리포트

@@ -61,7 +61,7 @@ export default function AdminPage() {
-
+
@@ -74,14 +74,14 @@ export default function AdminPage() {
{/* 표준 관리 섹션 */} -
+

표준 관리

-
- +
+

웹타입 관리

@@ -94,8 +94,8 @@ export default function AdminPage() {
-
- +
+

템플릿 관리

@@ -108,8 +108,8 @@ export default function AdminPage() {
-
- +
+

테이블 관리

@@ -122,8 +122,8 @@ export default function AdminPage() {
-
- +
+

컴포넌트 관리

diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 6cab32bf..f09f6bc8 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -7,14 +7,19 @@ import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Search, Database, RefreshCw, Settings, Menu, X } from "lucide-react"; +import { Search, Database, RefreshCw, Settings, Menu, X, Plus, Activity } from "lucide-react"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; import { useMultiLang } from "@/hooks/useMultiLang"; -import { TABLE_MANAGEMENT_KEYS, WEB_TYPE_OPTIONS_WITH_KEYS } from "@/constants/tableManagement"; +import { useAuth } from "@/hooks/useAuth"; +import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement"; +import { INPUT_TYPE_OPTIONS } from "@/types/input-types"; import { apiClient } from "@/lib/api/client"; import { commonCodeApi } from "@/lib/api/commonCode"; import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; +import { CreateTableModal } from "@/components/admin/CreateTableModal"; +import { AddColumnModal } from "@/components/admin/AddColumnModal"; +import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; // 가상화 스크롤링을 위한 간단한 구현 interface TableInfo { @@ -27,8 +32,7 @@ interface TableInfo { interface ColumnTypeInfo { columnName: string; displayName: string; - dbType: string; - webType: string; + inputType: string; // webType → inputType 변경 detailSettings: string; description: string; isNullable: string; @@ -45,6 +49,7 @@ interface ColumnTypeInfo { export default function TableManagementPage() { const { userLang, getText } = useMultiLang({ companyCode: "*" }); + const { user } = useAuth(); const [tables, setTables] = useState([]); const [columns, setColumns] = useState([]); const [selectedTable, setSelectedTable] = useState(null); @@ -66,6 +71,14 @@ export default function TableManagementPage() { // 🎯 Entity 조인 관련 상태 const [referenceTableColumns, setReferenceTableColumns] = useState>({}); + // DDL 기능 관련 상태 + const [createTableModalOpen, setCreateTableModalOpen] = useState(false); + const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); + const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false); + + // 최고 관리자 여부 확인 + const isSuperAdmin = user?.companyCode === "*" && user?.userId === "plm_admin"; + // 다국어 텍스트 로드 useEffect(() => { const loadTexts = async () => { @@ -135,15 +148,15 @@ export default function TableManagementPage() { [], // 의존성 배열에서 referenceTableColumns 제거 ); - // 웹 타입 옵션 (다국어 적용) - const webTypeOptions = WEB_TYPE_OPTIONS_WITH_KEYS.map((option) => ({ + // 입력 타입 옵션 (8개 핵심 타입) + const inputTypeOptions = INPUT_TYPE_OPTIONS.map((option) => ({ value: option.value, - label: getTextFromUI(option.labelKey, option.value), - description: getTextFromUI(option.descriptionKey, option.value), + label: option.label, + description: option.description, })); - // 메모이제이션된 웹타입 옵션 - const memoizedWebTypeOptions = useMemo(() => webTypeOptions, [uiTexts]); + // 메모이제이션된 입력타입 옵션 + const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []); // 참조 테이블 옵션 (실제 테이블 목록에서 가져옴) const referenceTableOptions = [ @@ -218,14 +231,21 @@ export default function TableManagementPage() { // 응답 상태 확인 if (response.data.success) { const data = response.data.data; + + // 컬럼 데이터에 기본값 설정 + const processedColumns = (data.columns || data).map((col: any) => ({ + ...col, + inputType: col.inputType || "text", // 기본값: text + })); + if (page === 1) { - setColumns(data.columns || data); - setOriginalColumns(data.columns || data); + setColumns(processedColumns); + setOriginalColumns(processedColumns); } else { // 페이지 추가 로드 시 기존 데이터에 추가 - setColumns((prev) => [...prev, ...(data.columns || data)]); + setColumns((prev) => [...prev, ...processedColumns]); } - setTotalColumns(data.total || (data.columns || data).length); + setTotalColumns(data.total || processedColumns.length); toast.success("컬럼 정보를 성공적으로 로드했습니다."); } else { toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다."); @@ -255,24 +275,24 @@ export default function TableManagementPage() { [loadColumnTypes, pageSize, tables], ); - // 웹 타입 변경 - const handleWebTypeChange = useCallback( - (columnName: string, newWebType: string) => { + // 입력 타입 변경 + const handleInputTypeChange = useCallback( + (columnName: string, newInputType: string) => { setColumns((prev) => prev.map((col) => { if (col.columnName === columnName) { - const webTypeOption = memoizedWebTypeOptions.find((option) => option.value === newWebType); + const inputTypeOption = memoizedInputTypeOptions.find((option) => option.value === newInputType); return { ...col, - webType: newWebType, - detailSettings: webTypeOption?.description || col.detailSettings, + inputType: newInputType, + detailSettings: inputTypeOption?.description || col.detailSettings, }; } return col; }), ); }, - [memoizedWebTypeOptions], + [memoizedInputTypeOptions], ); // 상세 설정 변경 (코드/엔티티 타입용) @@ -382,7 +402,7 @@ export default function TableManagementPage() { const columnSetting = { columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnLabel: column.displayName, // 사용자가 입력한 표시명 - webType: column.webType || "text", + inputType: column.inputType || "text", detailSettings: column.detailSettings || "", codeCategory: column.codeCategory || "", codeValue: column.codeValue || "", @@ -437,7 +457,7 @@ export default function TableManagementPage() { const columnSettings = columns.map((column) => ({ columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnLabel: column.displayName, // 사용자가 입력한 표시명 - webType: column.webType || "text", + inputType: column.inputType || "text", detailSettings: column.detailSettings || "", description: column.description || "", codeCategory: column.codeCategory || "", @@ -531,11 +551,44 @@ export default function TableManagementPage() {

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

+ {isSuperAdmin && ( +

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

+ )} +
+ +
+ {/* DDL 기능 버튼들 (최고 관리자만) */} + {isSuperAdmin && ( + <> + + + {selectedTable && ( + + )} + + + + )} + +
-
@@ -668,8 +721,7 @@ export default function TableManagementPage() {
컬럼명
라벨
-
DB 타입
-
웹 타입
+
입력 타입
상세 설정
@@ -703,21 +755,16 @@ export default function TableManagementPage() { className="h-7 text-xs" />
-
- - {column.dbType} - -
handleDetailSettingsChange(column.columnName, "code", value)} @@ -745,7 +792,7 @@ export default function TableManagementPage() { )} {/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */} - {column.webType === "entity" && ( + {column.inputType === "entity" && (
{/* 🎯 Entity 타입 설정 - 가로 배치 */}
@@ -880,7 +927,7 @@ export default function TableManagementPage() {
)} {/* 다른 웹 타입인 경우 빈 공간 */} - {column.webType !== "code" && column.webType !== "entity" && ( + {column.inputType !== "code" && column.inputType !== "entity" && (
-
)}
@@ -927,6 +974,47 @@ export default function TableManagementPage() {
+ + {/* DDL 모달 컴포넌트들 */} + {isSuperAdmin && ( + <> + setCreateTableModalOpen(false)} + onSuccess={async (result) => { + toast.success("테이블이 성공적으로 생성되었습니다!"); + // 테이블 목록 새로고침 + await loadTables(); + // 새로 생성된 테이블 자동 선택 및 컬럼 로드 + if (result.data?.tableName) { + setSelectedTable(result.data.tableName); + setCurrentPage(1); + setColumns([]); + await loadColumnTypes(result.data.tableName, 1, pageSize); + } + }} + /> + + setAddColumnModalOpen(false)} + tableName={selectedTable || ""} + onSuccess={async (result) => { + toast.success("컬럼이 성공적으로 추가되었습니다!"); + // 테이블 목록 새로고침 (컬럼 수 업데이트) + await loadTables(); + // 선택된 테이블의 컬럼 목록 새로고침 - 페이지 리셋 + if (selectedTable) { + setCurrentPage(1); + setColumns([]); // 기존 컬럼 목록 초기화 + await loadColumnTypes(selectedTable, 1, pageSize); + } + }} + /> + + setDdlLogViewerOpen(false)} /> + + )}
); } diff --git a/frontend/app/(main)/admin/validation-demo/page.tsx b/frontend/app/(main)/admin/validation-demo/page.tsx new file mode 100644 index 00000000..bb567d63 --- /dev/null +++ b/frontend/app/(main)/admin/validation-demo/page.tsx @@ -0,0 +1,585 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { toast } from "sonner"; +import { EnhancedInteractiveScreenViewer } from "@/components/screen/EnhancedInteractiveScreenViewer"; +import { FormValidationIndicator } from "@/components/common/FormValidationIndicator"; +import { useFormValidation } from "@/hooks/useFormValidation"; +import { enhancedFormService } from "@/lib/services/enhancedFormService"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { ComponentData, WidgetComponent, ColumnInfo, ScreenDefinition } from "@/types/screen"; +import { normalizeWebType } from "@/types/unified-web-types"; + +// 테스트용 화면 정의 +const TEST_SCREEN_DEFINITION: ScreenDefinition = { + id: 999, + screenName: "validation-demo", + tableName: "test_users", // 테스트용 테이블 + screenResolution: { width: 800, height: 600 }, + gridSettings: { size: 20, color: "#e0e0e0", opacity: 0.5 }, + description: "검증 시스템 데모 화면", +}; + +// 테스트용 컴포넌트 데이터 +const TEST_COMPONENTS: ComponentData[] = [ + { + id: "container-1", + type: "container", + x: 0, + y: 0, + width: 800, + height: 600, + parentId: null, + children: ["widget-1", "widget-2", "widget-3", "widget-4", "widget-5", "widget-6"], + }, + { + id: "widget-1", + type: "widget", + x: 20, + y: 20, + width: 200, + height: 40, + parentId: "container-1", + label: "사용자명", + widgetType: "text", + columnName: "user_name", + required: true, + style: { + labelFontSize: "14px", + labelColor: "#374151", + labelFontWeight: "500", + }, + } as WidgetComponent, + { + id: "widget-2", + type: "widget", + x: 20, + y: 80, + width: 200, + height: 40, + parentId: "container-1", + label: "이메일", + widgetType: "email", + columnName: "email", + required: true, + style: { + labelFontSize: "14px", + labelColor: "#374151", + labelFontWeight: "500", + }, + } as WidgetComponent, + { + id: "widget-3", + type: "widget", + x: 20, + y: 140, + width: 200, + height: 40, + parentId: "container-1", + label: "나이", + widgetType: "number", + columnName: "age", + required: false, + webTypeConfig: { + min: 0, + max: 120, + }, + style: { + labelFontSize: "14px", + labelColor: "#374151", + labelFontWeight: "500", + }, + } as WidgetComponent, + { + id: "widget-4", + type: "widget", + x: 20, + y: 200, + width: 200, + height: 40, + parentId: "container-1", + label: "생년월일", + widgetType: "date", + columnName: "birth_date", + required: false, + style: { + labelFontSize: "14px", + labelColor: "#374151", + labelFontWeight: "500", + }, + } as WidgetComponent, + { + id: "widget-5", + type: "widget", + x: 20, + y: 260, + width: 200, + height: 40, + parentId: "container-1", + label: "전화번호", + widgetType: "tel", + columnName: "phone", + required: false, + style: { + labelFontSize: "14px", + labelColor: "#374151", + labelFontWeight: "500", + }, + } as WidgetComponent, + { + id: "widget-6", + type: "widget", + x: 20, + y: 320, + width: 100, + height: 40, + parentId: "container-1", + label: "저장", + widgetType: "button", + columnName: "save_button", + required: false, + webTypeConfig: { + actionType: "save", + text: "저장하기", + }, + style: { + labelFontSize: "14px", + labelColor: "#374151", + labelFontWeight: "500", + }, + } as WidgetComponent, +]; + +// 테스트용 테이블 컬럼 정보 +const TEST_TABLE_COLUMNS: ColumnInfo[] = [ + { + tableName: "test_users", + columnName: "id", + columnLabel: "ID", + dataType: "integer", + webType: "number", + widgetType: "number", + inputType: "auto", + isNullable: "N", + required: false, + isPrimaryKey: true, + isVisible: false, + displayOrder: 0, + description: "기본키", + }, + { + tableName: "test_users", + columnName: "user_name", + columnLabel: "사용자명", + dataType: "character varying", + webType: "text", + widgetType: "text", + inputType: "direct", + isNullable: "N", + required: true, + characterMaximumLength: 50, + isVisible: true, + displayOrder: 1, + description: "사용자 이름", + }, + { + tableName: "test_users", + columnName: "email", + columnLabel: "이메일", + dataType: "character varying", + webType: "email", + widgetType: "email", + inputType: "direct", + isNullable: "N", + required: true, + characterMaximumLength: 100, + isVisible: true, + displayOrder: 2, + description: "이메일 주소", + }, + { + tableName: "test_users", + columnName: "age", + columnLabel: "나이", + dataType: "integer", + webType: "number", + widgetType: "number", + inputType: "direct", + isNullable: "Y", + required: false, + isVisible: true, + displayOrder: 3, + description: "나이", + }, + { + tableName: "test_users", + columnName: "birth_date", + columnLabel: "생년월일", + dataType: "date", + webType: "date", + widgetType: "date", + inputType: "direct", + isNullable: "Y", + required: false, + isVisible: true, + displayOrder: 4, + description: "생년월일", + }, + { + tableName: "test_users", + columnName: "phone", + columnLabel: "전화번호", + dataType: "character varying", + webType: "tel", + widgetType: "tel", + inputType: "direct", + isNullable: "Y", + required: false, + characterMaximumLength: 20, + isVisible: true, + displayOrder: 5, + description: "전화번호", + }, +]; + +export default function ValidationDemoPage() { + const [formData, setFormData] = useState>({}); + const [selectedTable, setSelectedTable] = useState("test_users"); + const [availableTables, setAvailableTables] = useState([]); + const [tableColumns, setTableColumns] = useState(TEST_TABLE_COLUMNS); + const [isLoading, setIsLoading] = useState(false); + + // 폼 검증 훅 사용 + const { validationState, saveState, validateForm, saveForm, canSave, getFieldError, hasFieldError, isFieldValid } = + useFormValidation( + formData, + TEST_COMPONENTS.filter((c) => c.type === "widget") as WidgetComponent[], + tableColumns, + TEST_SCREEN_DEFINITION, + { + enableRealTimeValidation: true, + validationDelay: 300, + enableAutoSave: false, + showToastMessages: true, + validateOnMount: false, + }, + ); + + // 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAvailableTables(response.data.map((table) => table.tableName)); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } + }; + loadTables(); + }, []); + + // 선택된 테이블의 컬럼 정보 로드 + useEffect(() => { + if (selectedTable && selectedTable !== "test_users") { + const loadTableColumns = async () => { + setIsLoading(true); + try { + const response = await tableManagementApi.getColumnList(selectedTable); + if (response.success && response.data) { + setTableColumns(response.data.columns || []); + } + } catch (error) { + console.error("테이블 컬럼 정보 로드 실패:", error); + toast.error("테이블 컬럼 정보를 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + loadTableColumns(); + } else { + setTableColumns(TEST_TABLE_COLUMNS); + } + }, [selectedTable]); + + const handleFormDataChange = (fieldName: string, value: any) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }; + + const handleTestFormSubmit = async () => { + const result = await saveForm(); + if (result) { + toast.success("폼 데이터가 성공적으로 저장되었습니다!"); + } + }; + + const handleManualValidation = async () => { + const result = await validateForm(); + toast.info( + `검증 완료: ${result.isValid ? "성공" : "실패"} (오류 ${result.errors.length}개, 경고 ${result.warnings.length}개)`, + ); + }; + + const generateTestData = () => { + setFormData({ + user_name: "테스트 사용자", + email: "test@example.com", + age: 25, + birth_date: "1999-01-01", + phone: "010-1234-5678", + }); + toast.info("테스트 데이터가 입력되었습니다."); + }; + + const generateInvalidData = () => { + setFormData({ + user_name: "", // 필수 필드 누락 + email: "invalid-email", // 잘못된 이메일 형식 + age: -5, // 음수 나이 + birth_date: "invalid-date", // 잘못된 날짜 + phone: "123", // 잘못된 전화번호 형식 + }); + toast.info("잘못된 테스트 데이터가 입력되었습니다."); + }; + + const clearForm = () => { + setFormData({}); + toast.info("폼이 초기화되었습니다."); + }; + + return ( +
+ {/* 헤더 */} +
+
+

검증 시스템 데모

+

개선된 폼 검증 시스템을 테스트해보세요

+
+
+ 개발 버전 + + {validationState.isValid ? "검증 통과" : "검증 실패"} + +
+
+ + + + 데모 폼 + 검증 상태 + 설정 + + + +
+ {/* 폼 영역 */} + + + 테스트 폼 + 실시간 검증이 적용된 폼입니다. 입력하면서 검증 결과를 확인해보세요. + + +
+ +
+
+
+ + {/* 컨트롤 패널 */} + + + 컨트롤 패널 + 테스트 기능들을 사용해보세요 + + +
+ +
+ + + +
+
+ + + +
+ +
+ + +
+
+ + + + +
+
+
+
+ + + + + 검증 상태 상세 + 현재 폼의 검증 상태를 자세히 확인할 수 있습니다 + + + + + + +
+

폼 데이터

+
+                  {JSON.stringify(formData, null, 2)}
+                
+
+ +
+

검증 통계

+
+
+
+ {Object.values(validationState.fieldStates).filter((f) => f.status === "valid").length} +
+
유효한 필드
+
+
+
{validationState.errors.length}
+
오류 개수
+
+
+
+
+
+
+ + + + + 테스트 설정 + 검증 동작을 조정할 수 있습니다 + + +
+ + +
+ + {isLoading && ( +
+
테이블 정보를 불러오는 중...
+
+ )} + +
+

테이블 컬럼 정보

+
+ + + + + + + + + + {tableColumns.map((column) => ( + + + + + + ))} + +
컬럼명타입필수
{column.columnName} + + {column.webType} + + + {column.required ? ( + + 필수 + + ) : ( + + 선택 + + )} +
+
+
+
+
+
+
+
+ ); +} diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index db85e8a7..90195801 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -13,6 +13,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; import { EditModal } from "@/components/screen/EditModal"; +// import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거 export default function ScreenViewPage() { const params = useParams(); @@ -364,7 +365,7 @@ export default function ScreenViewPage() { ) : ( // 빈 화면일 때도 깔끔하게 표시
+ {/* 테스트 네비게이션 */} + + + 🧪 타입 안전성 테스트 센터 + + +

+ 화면관리, 제어관리, 테이블타입관리 시스템의 타입 안전성을 다양한 방법으로 검증합니다. +

+ +
+ + + +
+
+
+ + {/* 기본 테스트 실행 */} + +
+ ); +} diff --git a/frontend/app/test-type-safety/simple-test.tsx b/frontend/app/test-type-safety/simple-test.tsx new file mode 100644 index 00000000..99f9b372 --- /dev/null +++ b/frontend/app/test-type-safety/simple-test.tsx @@ -0,0 +1,242 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + ComponentData, + WebType, + ButtonActionType, + WidgetComponent, + isWidgetComponent, + isWebType, + isButtonActionType, + ynToBoolean, + booleanToYN, +} from "@/types"; + +export default function SimpleTypeSafetyTest() { + const [testResults, setTestResults] = useState< + Array<{ name: string; status: "pending" | "passed" | "failed"; message?: string }> + >([]); + const [isRunning, setIsRunning] = useState(false); + + const addResult = (name: string, status: "passed" | "failed", message?: string) => { + setTestResults((prev) => [...prev, { name, status, message }]); + }; + + const runSimpleTests = () => { + setIsRunning(true); + setTestResults([]); + + try { + // Test 1: WebType 검증 + const validWebTypes = ["text", "number", "date", "select", "checkbox"]; + const invalidWebTypes = ["text_area", "VARCHAR", "submit"]; + + let webTypeTestPassed = true; + validWebTypes.forEach((type) => { + if (!isWebType(type)) webTypeTestPassed = false; + }); + invalidWebTypes.forEach((type) => { + if (isWebType(type as any)) webTypeTestPassed = false; + }); + + addResult( + "WebType 타입 검증", + webTypeTestPassed ? "passed" : "failed", + webTypeTestPassed ? "모든 WebType 검증 통과" : "일부 WebType 검증 실패", + ); + + // Test 2: ButtonActionType 검증 + const validActions: ButtonActionType[] = ["save", "cancel", "delete", "edit"]; + const invalidActions = ["insert", "update", ""]; + + let buttonActionTestPassed = true; + validActions.forEach((action) => { + if (!isButtonActionType(action)) buttonActionTestPassed = false; + }); + invalidActions.forEach((action) => { + if (isButtonActionType(action as any)) buttonActionTestPassed = false; + }); + + addResult( + "ButtonActionType 검증", + buttonActionTestPassed ? "passed" : "failed", + buttonActionTestPassed ? "모든 ButtonActionType 검증 통과" : "일부 ButtonActionType 검증 실패", + ); + + // Test 3: Y/N ↔ boolean 변환 + const ynTests = [ + { input: "Y", expected: true }, + { input: "N", expected: false }, + { input: "", expected: false }, + { input: undefined, expected: false }, + ]; + + let ynTestPassed = true; + ynTests.forEach(({ input, expected }) => { + if (ynToBoolean(input) !== expected) ynTestPassed = false; + }); + + if (booleanToYN(true) !== "Y" || booleanToYN(false) !== "N") ynTestPassed = false; + + addResult( + "Y/N ↔ boolean 변환", + ynTestPassed ? "passed" : "failed", + ynTestPassed ? "모든 Y/N 변환 테스트 통과" : "Y/N 변환 테스트 실패", + ); + + // Test 4: 컴포넌트 타입 가드 + const testWidget: WidgetComponent = { + id: "test-widget", + type: "widget", + widgetType: "text", + position: { x: 0, y: 0 }, + size: { width: 200, height: 40 }, + label: "테스트", + webTypeConfig: {}, + }; + + const testContainer = { + id: "test-container", + type: "container", + position: { x: 0, y: 0 }, + size: { width: 400, height: 300 }, + children: [], + }; + + let typeGuardTestPassed = true; + if (!isWidgetComponent(testWidget)) typeGuardTestPassed = false; + if (isWidgetComponent(testContainer)) typeGuardTestPassed = false; + + addResult( + "컴포넌트 타입 가드", + typeGuardTestPassed ? "passed" : "failed", + typeGuardTestPassed ? "타입 가드 모든 테스트 통과" : "타입 가드 테스트 실패", + ); + + // Test 5: 폼 데이터 처리 시뮬레이션 + const formData = { + userName: "테스트 사용자", + userAge: 25, + isActive: true, + }; + + const formComponents: WidgetComponent[] = [ + { + id: "userName", + type: "widget", + widgetType: "text", + position: { x: 0, y: 0 }, + size: { width: 200, height: 40 }, + label: "사용자명", + columnName: "user_name", + webTypeConfig: {}, + }, + { + id: "isActive", + type: "widget", + widgetType: "checkbox", + position: { x: 0, y: 50 }, + size: { width: 200, height: 40 }, + label: "활성화", + columnName: "is_active", + webTypeConfig: {}, + }, + ]; + + const processedData: Record = {}; + formComponents.forEach((component) => { + const fieldValue = formData[component.id as keyof typeof formData]; + if (fieldValue !== undefined && component.columnName) { + switch (component.widgetType) { + case "text": + processedData[component.columnName] = String(fieldValue); + break; + case "checkbox": + processedData[component.columnName] = booleanToYN(Boolean(fieldValue)); + break; + default: + processedData[component.columnName] = fieldValue; + } + } + }); + + let formProcessingTestPassed = true; + if (typeof processedData.user_name !== "string") formProcessingTestPassed = false; + if (processedData.is_active !== "Y" && processedData.is_active !== "N") formProcessingTestPassed = false; + + addResult( + "폼 데이터 처리", + formProcessingTestPassed ? "passed" : "failed", + formProcessingTestPassed ? "폼 데이터 타입 안전 처리 성공" : "폼 데이터 처리 실패", + ); + } catch (error) { + addResult("전체 테스트", "failed", `테스트 실행 중 오류: ${error}`); + } + + setIsRunning(false); + }; + + const passedTests = testResults.filter((test) => test.status === "passed").length; + const totalTests = testResults.length; + + return ( +
+
+

🧪 타입 안전성 간단 테스트

+

+ 화면관리, 제어관리, 테이블타입관리 시스템의 핵심 타입 안전성을 검증합니다 +

+ + +
+ + {testResults.length > 0 && ( + + + 📊 테스트 결과 + + +
+
+
+ {passedTests}/{totalTests} 통과 +
+
+ 성공률: {totalTests > 0 ? Math.round((passedTests / totalTests) * 100) : 0}% +
+
+ + {testResults.map((test, index) => ( +
+
+
{test.name}
+ {test.message &&
{test.message}
} +
+ + + {test.status === "passed" ? "통과" : "실패"} + +
+ ))} +
+ + {passedTests === totalTests && totalTests > 0 && ( +
+
🎉 모든 타입 안전성 테스트가 통과되었습니다!
+
+ 화면관리, 제어관리, 테이블타입관리 시스템이 안전하게 작동합니다. +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/app/test-type-safety/stress-test.tsx b/frontend/app/test-type-safety/stress-test.tsx new file mode 100644 index 00000000..5d780f29 --- /dev/null +++ b/frontend/app/test-type-safety/stress-test.tsx @@ -0,0 +1,317 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import StressTestSuite from "../../test-scenarios/stress-test-scenarios"; + +interface TestResult { + testName: string; + status: "passed" | "failed" | "warning"; + duration: number; + details: string; + metrics?: any; +} + +interface StressTestResults { + success: boolean; + totalTests: number; + passedTests: number; + failedTests: number; + warningTests: number; + totalDuration: number; + results: TestResult[]; + recommendation: string[]; +} + +export default function StressTestPage() { + const [isRunning, setIsRunning] = useState(false); + const [currentTest, setCurrentTest] = useState(""); + const [progress, setProgress] = useState(0); + const [testResults, setTestResults] = useState(null); + const [testLogs, setTestLogs] = useState([]); + + const runStressTests = async () => { + setIsRunning(true); + setTestResults(null); + setTestLogs([]); + setProgress(0); + + // 콘솔 로그를 캡처하기 위한 오버라이드 + const originalLog = console.log; + const capturedLogs: string[] = []; + + console.log = (...args) => { + const logMessage = args.join(" "); + capturedLogs.push(logMessage); + setTestLogs((prev) => [...prev, logMessage]); + originalLog(...args); + }; + + try { + // 개별 테스트 진행 상황 모니터링 + const testNames = [ + "대량 데이터 처리", + "타입 오염 및 손상", + "동시 작업 및 경합 상태", + "메모리 부하 및 가비지 컬렉션", + "API 스트레스 및 네트워크 시뮬레이션", + ]; + + // 각 테스트 시작 시 진행률 업데이트 + let completedTests = 0; + + const updateProgress = (testName: string) => { + setCurrentTest(testName); + setProgress((completedTests / testNames.length) * 100); + }; + + // 테스트 실행 (실제로는 StressTestSuite.runAllStressTests()가 모든 테스트를 순차 실행) + updateProgress(testNames[0]); + + // 진행률 시뮬레이션을 위한 간격 업데이트 + const progressInterval = setInterval(() => { + completedTests = Math.min(completedTests + 0.1, testNames.length - 0.1); + const currentTestIndex = Math.floor(completedTests); + if (currentTestIndex < testNames.length) { + setCurrentTest(testNames[currentTestIndex]); + } + setProgress((completedTests / testNames.length) * 100); + }, 200); + + const results = await StressTestSuite.runAllStressTests(); + + clearInterval(progressInterval); + setProgress(100); + setCurrentTest("완료"); + setTestResults(results as StressTestResults); + } catch (error) { + console.error("스트레스 테스트 실행 중 오류:", error); + setTestResults({ + success: false, + totalTests: 0, + passedTests: 0, + failedTests: 1, + warningTests: 0, + totalDuration: 0, + results: [], + recommendation: [`스트레스 테스트 실행 중 오류가 발생했습니다: ${error}`], + }); + } finally { + // 콘솔 로그 복원 + console.log = originalLog; + setIsRunning(false); + setCurrentTest(""); + } + }; + + const getStatusBadgeVariant = (status: string) => { + switch (status) { + case "passed": + return "default"; + case "failed": + return "destructive"; + case "warning": + return "secondary"; + default: + return "outline"; + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case "passed": + return "✅"; + case "failed": + return "❌"; + case "warning": + return "⚠️"; + default: + return "❓"; + } + }; + + return ( +
+
+

🔥 타입 시스템 스트레스 테스트

+

극한 상황에서 타입 시스템의 견고함과 성능을 검증합니다

+
+ ⚠️ 주의: 이 테스트는 시스템에 높은 부하를 가할 수 있습니다 +
+ + +
+ + {/* 진행률 표시 */} + {isRunning && ( + + + 📊 테스트 진행 상황 + + +
+
+ 현재 테스트: {currentTest} + {Math.round(progress)}% +
+ +
+
+
+ )} + + {/* 테스트 결과 */} + {testResults && ( +
+ {/* 요약 */} + + + + 📈 스트레스 테스트 결과 요약 + {testResults.success ? "🎉" : "⚠️"} + + + +
+
+
{testResults.passedTests}
+
통과
+
+
+
{testResults.failedTests}
+
실패
+
+
+
{testResults.warningTests}
+
경고
+
+
+
{Math.round(testResults.totalDuration)}ms
+
총 소요시간
+
+
+ +
+
+ 성공률:{" "} + {testResults.totalTests > 0 + ? Math.round((testResults.passedTests / testResults.totalTests) * 100) + : 0} + % +
+
+ + {testResults.success ? ( +
+
🎉 모든 스트레스 테스트 통과!
+
+ 타입 시스템이 극한 상황에서도 안정적으로 작동합니다. +
+
+ ) : ( +
+
⚠️ 일부 스트레스 테스트 실패
+
+ 개선이 필요한 영역이 있습니다. 아래 권장사항을 확인하세요. +
+
+ )} +
+
+ + {/* 개별 테스트 결과 */} + + + 🔍 개별 테스트 상세 결과 + + + {testResults.results.map((result, index) => ( +
+
+
+ {getStatusIcon(result.status)} +

{result.testName}

+
+ + {result.status === "passed" ? "통과" : result.status === "failed" ? "실패" : "경고"} + +
+ +
{result.details}
+ +
소요시간: {Math.round(result.duration)}ms
+ + {/* 메트릭스 표시 */} + {result.metrics && ( +
+
📊 상세 메트릭스:
+
+ {Object.entries(result.metrics).map(([key, value]) => ( +
+ {key}: + + {typeof value === "number" + ? Number.isInteger(value) + ? value.toLocaleString() + : value.toFixed(2) + : String(value)} + +
+ ))} +
+
+ )} +
+ ))} +
+
+ + {/* 권장사항 */} + {testResults.recommendation.length > 0 && ( + + + 💡 개선 권장사항 + + +
    + {testResults.recommendation.map((rec, index) => ( +
  • + + {rec} +
  • + ))} +
+
+
+ )} +
+ )} + + {/* 실시간 로그 (축소된 형태) */} + {testLogs.length > 0 && ( + + + 📋 테스트 로그 (최근 10개) + + +
+ {testLogs.slice(-10).map((log, index) => ( +
{log}
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/frontend/app/test-type-safety/stress-test/page.tsx b/frontend/app/test-type-safety/stress-test/page.tsx new file mode 100644 index 00000000..badca99b --- /dev/null +++ b/frontend/app/test-type-safety/stress-test/page.tsx @@ -0,0 +1,5 @@ +import StressTestPage from "../stress-test"; + +export default function StressTestRoutePage() { + return ; +} diff --git a/frontend/components/admin/AddColumnModal.tsx b/frontend/components/admin/AddColumnModal.tsx new file mode 100644 index 00000000..2fd6c33e --- /dev/null +++ b/frontend/components/admin/AddColumnModal.tsx @@ -0,0 +1,365 @@ +/** + * 컬럼 추가 모달 컴포넌트 + * 기존 테이블에 새로운 컬럼을 추가하기 위한 모달 + */ + +"use client"; + +import { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, Plus, AlertCircle } from "lucide-react"; +import { toast } from "sonner"; +import { ddlApi } from "../../lib/api/ddl"; +import { + AddColumnModalProps, + CreateColumnDefinition, + VALIDATION_RULES, + RESERVED_WORDS, + RESERVED_COLUMNS, +} from "../../types/ddl"; +import { INPUT_TYPE_OPTIONS } from "../../types/input-types"; + +export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddColumnModalProps) { + const [column, setColumn] = useState({ + name: "", + label: "", + inputType: "text", + nullable: true, + order: 0, + }); + const [loading, setLoading] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + + /** + * 모달 리셋 + */ + const resetModal = () => { + setColumn({ + name: "", + label: "", + inputType: "text", + nullable: true, + order: 0, + }); + setValidationErrors([]); + }; + + /** + * 모달 열림/닫힘 시 리셋 + */ + useEffect(() => { + if (isOpen) { + resetModal(); + } + }, [isOpen]); + + /** + * 컬럼 정보 업데이트 + */ + const updateColumn = (updates: Partial) => { + const newColumn = { ...column, ...updates }; + setColumn(newColumn); + + // 업데이트 후 검증 + validateColumn(newColumn); + }; + + /** + * 컬럼 검증 + */ + const validateColumn = (columnData: CreateColumnDefinition) => { + const errors: string[] = []; + + // 컬럼명 검증 + if (!columnData.name) { + errors.push("컬럼명은 필수입니다."); + } else { + if (!VALIDATION_RULES.columnName.pattern.test(columnData.name)) { + errors.push(VALIDATION_RULES.columnName.errorMessage); + } + + if ( + columnData.name.length < VALIDATION_RULES.columnName.minLength || + columnData.name.length > VALIDATION_RULES.columnName.maxLength + ) { + errors.push( + `컬럼명은 ${VALIDATION_RULES.columnName.minLength}-${VALIDATION_RULES.columnName.maxLength}자여야 합니다.`, + ); + } + + // 예약어 검증 + if (RESERVED_WORDS.includes(columnData.name.toLowerCase() as any)) { + errors.push("SQL 예약어는 컬럼명으로 사용할 수 없습니다."); + } + + // 예약된 컬럼명 검증 + if (RESERVED_COLUMNS.includes(columnData.name.toLowerCase() as any)) { + errors.push("이미 자동 추가되는 기본 컬럼명입니다."); + } + + // 네이밍 컨벤션 검증 + if (columnData.name.startsWith("_") || columnData.name.endsWith("_")) { + errors.push("컬럼명은 언더스코어로 시작하거나 끝날 수 없습니다."); + } + + if (columnData.name.includes("__")) { + errors.push("컬럼명에 연속된 언더스코어는 사용할 수 없습니다."); + } + } + + // 입력타입 검증 + if (!columnData.inputType) { + errors.push("입력타입을 선택해주세요."); + } + + // 길이 검증 (길이를 지원하는 타입인 경우) + const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === columnData.inputType); + if (inputTypeOption?.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 handleInputTypeChange = (inputType: string) => { + const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === inputType); + const updates: Partial = { inputType: inputType as any }; + + // 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정 + if (inputTypeOption?.supportsLength && !column.length && inputTypeOption.defaultLength) { + updates.length = inputTypeOption.defaultLength; + } + + // 길이를 지원하지 않는 타입이면 길이 제거 + if (!inputTypeOption?.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.inputType; + + const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType); + + return ( + + + + + + 컬럼 추가 - {tableName} + + + +
+ {/* 검증 오류 표시 */} + {validationErrors.length > 0 && ( + + + +
+ {validationErrors.map((error, index) => ( +
• {error}
+ ))} +
+
+
+ )} + + {/* 기본 정보 */} +
+
+ + updateColumn({ name: e.target.value })} + placeholder="column_name" + disabled={loading} + className={validationErrors.some((e) => e.includes("컬럼명")) ? "border-red-300" : ""} + /> +

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

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

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

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

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

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