저장버튼 제어기능 (insert)

This commit is contained in:
kjs
2025-09-18 10:05:50 +09:00
parent 7b7f81d85c
commit 7cbbf45dc9
32 changed files with 8500 additions and 116 deletions

View File

@@ -0,0 +1,200 @@
/**
* 🔥 버튼 제어관리 성능 최적화 인덱스 설치 스크립트
*
* 사용법:
* node scripts/install-dataflow-indexes.js
*/
const { PrismaClient } = require("@prisma/client");
const fs = require("fs");
const path = require("path");
const prisma = new PrismaClient();
async function installDataflowIndexes() {
try {
console.log("🔥 Starting Button Dataflow Performance Optimization...\n");
// SQL 파일 읽기
const sqlFilePath = path.join(
__dirname,
"../database/migrations/add_button_dataflow_indexes.sql"
);
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
console.log("📖 Reading SQL migration file...");
console.log(`📁 File: ${sqlFilePath}\n`);
// 데이터베이스 연결 확인
console.log("🔍 Checking database connection...");
await prisma.$queryRaw`SELECT 1`;
console.log("✅ Database connection OK\n");
// 기존 인덱스 상태 확인
console.log("🔍 Checking existing indexes...");
const existingIndexes = await prisma.$queryRaw`
SELECT indexname, tablename
FROM pg_indexes
WHERE tablename = 'dataflow_diagrams'
AND indexname LIKE 'idx_dataflow%'
ORDER BY indexname;
`;
if (existingIndexes.length > 0) {
console.log("📋 Existing dataflow indexes:");
existingIndexes.forEach((idx) => {
console.log(` - ${idx.indexname}`);
});
} else {
console.log("📋 No existing dataflow indexes found");
}
console.log("");
// 테이블 상태 확인
console.log("🔍 Checking dataflow_diagrams table stats...");
const tableStats = await prisma.$queryRaw`
SELECT
COUNT(*) as total_rows,
COUNT(*) FILTER (WHERE control IS NOT NULL) as with_control,
COUNT(*) FILTER (WHERE plan IS NOT NULL) as with_plan,
COUNT(*) FILTER (WHERE category IS NOT NULL) as with_category,
COUNT(DISTINCT company_code) as companies
FROM dataflow_diagrams;
`;
if (tableStats.length > 0) {
const stats = tableStats[0];
console.log(`📊 Table Statistics:`);
console.log(` - Total rows: ${stats.total_rows}`);
console.log(` - With control: ${stats.with_control}`);
console.log(` - With plan: ${stats.with_plan}`);
console.log(` - With category: ${stats.with_category}`);
console.log(` - Companies: ${stats.companies}`);
}
console.log("");
// SQL 실행
console.log("🚀 Installing performance indexes...");
console.log("⏳ This may take a few minutes for large datasets...\n");
const startTime = Date.now();
// SQL을 문장별로 나누어 실행 (PostgreSQL 함수 때문에)
const sqlStatements = sqlContent
.split(/;\s*(?=\n|$)/)
.filter(
(stmt) =>
stmt.trim().length > 0 &&
!stmt.trim().startsWith("--") &&
!stmt.trim().startsWith("/*")
);
for (let i = 0; i < sqlStatements.length; i++) {
const statement = sqlStatements[i].trim();
if (statement.length === 0) continue;
try {
// DO 블록이나 복합 문장 처리
if (
statement.includes("DO $$") ||
statement.includes("CREATE OR REPLACE VIEW")
) {
console.log(
`⚡ Executing statement ${i + 1}/${sqlStatements.length}...`
);
await prisma.$executeRawUnsafe(statement + ";");
} else if (statement.startsWith("CREATE INDEX")) {
const indexName =
statement.match(/CREATE INDEX[^"]*"?([^"\s]+)"?/)?.[1] || "unknown";
console.log(`🔧 Creating index: ${indexName}...`);
await prisma.$executeRawUnsafe(statement + ";");
} else if (statement.startsWith("ANALYZE")) {
console.log(`📊 Analyzing table statistics...`);
await prisma.$executeRawUnsafe(statement + ";");
} else {
await prisma.$executeRawUnsafe(statement + ";");
}
} catch (error) {
// 이미 존재하는 인덱스 에러는 무시
if (error.message.includes("already exists")) {
console.log(`⚠️ Index already exists, skipping...`);
} else {
console.error(`❌ Error executing statement: ${error.message}`);
console.error(`📝 Statement: ${statement.substring(0, 100)}...`);
}
}
}
const endTime = Date.now();
const executionTime = (endTime - startTime) / 1000;
console.log(
`\n✅ Index installation completed in ${executionTime.toFixed(2)} seconds!`
);
// 설치된 인덱스 확인
console.log("\n🔍 Verifying installed indexes...");
const newIndexes = await prisma.$queryRaw`
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) as size
FROM pg_stat_user_indexes
WHERE tablename = 'dataflow_diagrams'
AND indexname LIKE 'idx_dataflow%'
ORDER BY indexname;
`;
if (newIndexes.length > 0) {
console.log("📋 Installed indexes:");
newIndexes.forEach((idx) => {
console.log(`${idx.indexname} (${idx.size})`);
});
}
// 성능 통계 조회
console.log("\n📊 Performance statistics:");
try {
const perfStats =
await prisma.$queryRaw`SELECT * FROM dataflow_performance_stats;`;
if (perfStats.length > 0) {
const stats = perfStats[0];
console.log(` - Table size: ${stats.table_size}`);
console.log(` - Total diagrams: ${stats.total_rows}`);
console.log(` - With control: ${stats.with_control}`);
console.log(` - Companies: ${stats.companies}`);
}
} catch (error) {
console.log(" ⚠️ Performance view not available yet");
}
console.log("\n🎯 Performance Optimization Complete!");
console.log("Expected improvements:");
console.log(" - Button dataflow lookup: 500ms+ → 10-50ms ⚡");
console.log(" - Category filtering: 200ms+ → 5-20ms ⚡");
console.log(" - Company queries: 100ms+ → 5-15ms ⚡");
console.log("\n💡 Monitor performance with:");
console.log(" SELECT * FROM dataflow_performance_stats;");
console.log(" SELECT * FROM dataflow_index_efficiency;");
} catch (error) {
console.error("\n❌ Error installing dataflow indexes:", error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
// 실행
if (require.main === module) {
installDataflowIndexes()
.then(() => {
console.log("\n🎉 Installation completed successfully!");
process.exit(0);
})
.catch((error) => {
console.error("\n💥 Installation failed:", error);
process.exit(1);
});
}
module.exports = { installDataflowIndexes };

View File

@@ -22,6 +22,8 @@ import fileRoutes from "./routes/fileRoutes";
import companyManagementRoutes from "./routes/companyManagementRoutes";
// import dataflowRoutes from "./routes/dataflowRoutes"; // 임시 주석
import dataflowDiagramRoutes from "./routes/dataflowDiagramRoutes";
import buttonDataflowRoutes from "./routes/buttonDataflowRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
import screenStandardRoutes from "./routes/screenStandardRoutes";
@@ -114,6 +116,8 @@ app.use("/api/files", fileRoutes);
app.use("/api/company-management", companyManagementRoutes);
// app.use("/api/dataflow", dataflowRoutes); // 임시 주석
app.use("/api/dataflow-diagrams", dataflowDiagramRoutes);
app.use("/api/button-dataflow", buttonDataflowRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
app.use("/api/admin/web-types", webTypeStandardRoutes);
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
app.use("/api/admin/template-standards", templateStandardRoutes);

View File

@@ -0,0 +1,653 @@
/**
* 🔥 버튼 데이터플로우 컨트롤러
*
* 성능 최적화를 위한 API 엔드포인트:
* 1. 즉시 응답 패턴
* 2. 백그라운드 작업 처리
* 3. 캐시 활용
*/
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import EventTriggerService from "../services/eventTriggerService";
import * as dataflowDiagramService from "../services/dataflowDiagramService";
import logger from "../utils/logger";
/**
* 🔥 버튼 설정 조회 (캐시 지원)
*/
export async function getButtonDataflowConfig(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { buttonId } = req.params;
const companyCode = req.user?.companyCode;
if (!buttonId) {
res.status(400).json({
success: false,
message: "버튼 ID가 필요합니다.",
});
return;
}
// 버튼별 제어관리 설정 조회
// TODO: 실제 버튼 설정 테이블에서 조회
// 현재는 mock 데이터 반환
const mockConfig = {
controlMode: "simple",
selectedDiagramId: 1,
selectedRelationshipId: "rel-123",
executionOptions: {
rollbackOnError: true,
enableLogging: true,
asyncExecution: true,
},
};
res.json({
success: true,
data: mockConfig,
});
} catch (error) {
logger.error("Failed to get button dataflow config:", error);
res.status(500).json({
success: false,
message: "버튼 설정 조회 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥 버튼 설정 업데이트
*/
export async function updateButtonDataflowConfig(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { buttonId } = req.params;
const config = req.body;
const companyCode = req.user?.companyCode;
if (!buttonId) {
res.status(400).json({
success: false,
message: "버튼 ID가 필요합니다.",
});
return;
}
// TODO: 실제 버튼 설정 테이블에 저장
logger.info(`Button dataflow config updated: ${buttonId}`, config);
res.json({
success: true,
message: "버튼 설정이 업데이트되었습니다.",
});
} catch (error) {
logger.error("Failed to update button dataflow config:", error);
res.status(500).json({
success: false,
message: "버튼 설정 업데이트 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥 사용 가능한 관계도 목록 조회
*/
export async function getAvailableDiagrams(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(400).json({
success: false,
message: "회사 코드가 필요합니다.",
});
return;
}
const diagramsResult = await dataflowDiagramService.getDataflowDiagrams(
companyCode,
1,
100
);
const diagrams = diagramsResult.diagrams;
res.json({
success: true,
data: diagrams,
});
} catch (error) {
logger.error("Failed to get available diagrams:", error);
res.status(500).json({
success: false,
message: "관계도 목록 조회 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥 특정 관계도의 관계 목록 조회
*/
export async function getDiagramRelationships(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { diagramId } = req.params;
const companyCode = req.user?.companyCode;
if (!diagramId || !companyCode) {
res.status(400).json({
success: false,
message: "관계도 ID와 회사 코드가 필요합니다.",
});
return;
}
const diagram = await dataflowDiagramService.getDataflowDiagramById(
parseInt(diagramId),
companyCode
);
if (!diagram) {
res.status(404).json({
success: false,
message: "관계도를 찾을 수 없습니다.",
});
return;
}
const relationships = (diagram.relationships as any)?.relationships || [];
res.json({
success: true,
data: relationships,
});
} catch (error) {
logger.error("Failed to get diagram relationships:", error);
res.status(500).json({
success: false,
message: "관계 목록 조회 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥 관계 미리보기 정보 조회
*/
export async function getRelationshipPreview(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { diagramId, relationshipId } = req.params;
const companyCode = req.user?.companyCode;
if (!diagramId || !relationshipId || !companyCode) {
res.status(400).json({
success: false,
message: "관계도 ID, 관계 ID, 회사 코드가 필요합니다.",
});
return;
}
const diagram = await dataflowDiagramService.getDataflowDiagramById(
parseInt(diagramId),
companyCode
);
if (!diagram) {
res.status(404).json({
success: false,
message: "관계도를 찾을 수 없습니다.",
});
return;
}
// 관계 정보 찾기
const relationship = (diagram.relationships as any)?.relationships?.find(
(rel: any) => rel.id === relationshipId
);
if (!relationship) {
res.status(404).json({
success: false,
message: "관계를 찾을 수 없습니다.",
});
return;
}
// 제어 및 계획 정보 추출
const control = Array.isArray(diagram.control)
? diagram.control.find((c: any) => c.id === relationshipId)
: null;
const plan = Array.isArray(diagram.plan)
? diagram.plan.find((p: any) => p.id === relationshipId)
: null;
const previewData = {
relationship,
control,
plan,
conditionsCount: (control as any)?.conditions?.length || 0,
actionsCount: (plan as any)?.actions?.length || 0,
};
res.json({
success: true,
data: previewData,
});
} catch (error) {
logger.error("Failed to get relationship preview:", error);
res.status(500).json({
success: false,
message: "관계 미리보기 조회 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥 최적화된 버튼 실행 (즉시 응답)
*/
export async function executeOptimizedButton(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const {
buttonId,
actionType,
buttonConfig,
contextData,
timing = "after",
} = req.body;
const companyCode = req.user?.companyCode;
if (!buttonId || !actionType || !companyCode) {
res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
return;
}
const startTime = Date.now();
// 🔥 타이밍에 따른 즉시 응답 처리
if (timing === "after") {
// After: 기존 액션 즉시 실행 + 백그라운드 제어관리
const immediateResult = await executeOriginalAction(
actionType,
contextData
);
// 제어관리는 백그라운드에서 처리 (실제로는 큐에 추가)
const jobId = `job_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
// TODO: 실제 작업 큐에 추가
processDataflowInBackground(
jobId,
buttonConfig,
contextData,
companyCode,
"normal"
);
const responseTime = Date.now() - startTime;
logger.info(`Button executed (after): ${responseTime}ms`);
res.json({
success: true,
data: {
jobId,
immediateResult,
isBackground: true,
timing: "after",
responseTime,
},
});
} else if (timing === "before") {
// Before: 간단한 검증 후 기존 액션
const isSimpleValidation = checkIfSimpleValidation(buttonConfig);
if (isSimpleValidation) {
// 간단한 검증: 즉시 처리
const validationResult = await validateQuickly(
buttonConfig,
contextData
);
if (!validationResult.success) {
res.json({
success: true,
data: {
jobId: "validation_failed",
immediateResult: validationResult,
timing: "before",
},
});
return;
}
// 검증 통과 시 기존 액션 실행
const actionResult = await executeOriginalAction(
actionType,
contextData
);
const responseTime = Date.now() - startTime;
logger.info(`Button executed (before-simple): ${responseTime}ms`);
res.json({
success: true,
data: {
jobId: "immediate",
immediateResult: actionResult,
timing: "before",
responseTime,
},
});
} else {
// 복잡한 검증: 백그라운드 처리
const jobId = `job_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
// TODO: 실제 작업 큐에 추가 (높은 우선순위)
processDataflowInBackground(
jobId,
buttonConfig,
contextData,
companyCode,
"high"
);
res.json({
success: true,
data: {
jobId,
immediateResult: {
success: true,
message: "검증 중입니다. 잠시만 기다려주세요.",
processing: true,
},
isBackground: true,
timing: "before",
},
});
}
} else if (timing === "replace") {
// Replace: 제어관리만 실행
const isSimpleControl = checkIfSimpleControl(buttonConfig);
if (isSimpleControl) {
// 간단한 제어: 즉시 실행
const result = await executeSimpleDataflowAction(
buttonConfig,
contextData,
companyCode
);
const responseTime = Date.now() - startTime;
logger.info(`Button executed (replace-simple): ${responseTime}ms`);
res.json({
success: true,
data: {
jobId: "immediate",
immediateResult: result,
timing: "replace",
responseTime,
},
});
} else {
// 복잡한 제어: 백그라운드 실행
const jobId = `job_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
// TODO: 실제 작업 큐에 추가
processDataflowInBackground(
jobId,
buttonConfig,
contextData,
companyCode,
"normal"
);
res.json({
success: true,
data: {
jobId,
immediateResult: {
success: true,
message: "사용자 정의 작업을 처리 중입니다...",
processing: true,
},
isBackground: true,
timing: "replace",
},
});
}
}
} catch (error) {
logger.error("Failed to execute optimized button:", error);
res.status(500).json({
success: false,
message: "버튼 실행 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥 간단한 데이터플로우 즉시 실행
*/
export async function executeSimpleDataflow(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { config, contextData } = req.body;
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(400).json({
success: false,
message: "회사 코드가 필요합니다.",
});
return;
}
const result = await executeSimpleDataflowAction(
config,
contextData,
companyCode
);
res.json({
success: true,
data: result,
});
} catch (error) {
logger.error("Failed to execute simple dataflow:", error);
res.status(500).json({
success: false,
message: "간단한 제어관리 실행 중 오류가 발생했습니다.",
});
}
}
/**
* 🔥 백그라운드 작업 상태 조회
*/
export async function getJobStatus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { jobId } = req.params;
// TODO: 실제 작업 큐에서 상태 조회
// 현재는 mock 응답
const mockStatus = {
status: "completed",
result: {
success: true,
executedActions: 2,
message: "백그라운드 처리가 완료되었습니다.",
},
progress: 100,
};
res.json({
success: true,
data: mockStatus,
});
} catch (error) {
logger.error("Failed to get job status:", error);
res.status(500).json({
success: false,
message: "작업 상태 조회 중 오류가 발생했습니다.",
});
}
}
// ============================================================================
// 🔥 헬퍼 함수들
// ============================================================================
/**
* 기존 액션 실행 (mock)
*/
async function executeOriginalAction(
actionType: string,
contextData: Record<string, any>
): Promise<any> {
// 간단한 지연 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 50));
return {
success: true,
message: `${actionType} 액션이 완료되었습니다.`,
actionType,
timestamp: new Date().toISOString(),
data: contextData,
};
}
/**
* 간단한 검증인지 확인
*/
function checkIfSimpleValidation(buttonConfig: any): boolean {
if (buttonConfig?.dataflowConfig?.controlMode !== "advanced") {
return true;
}
const conditions =
buttonConfig?.dataflowConfig?.directControl?.conditions || [];
return (
conditions.length <= 5 &&
conditions.every(
(c: any) =>
c.type === "condition" &&
["=", "!=", ">", "<", ">=", "<="].includes(c.operator || "")
)
);
}
/**
* 간단한 제어관리인지 확인
*/
function checkIfSimpleControl(buttonConfig: any): boolean {
if (buttonConfig?.dataflowConfig?.controlMode === "simple") {
return true;
}
const actions = buttonConfig?.dataflowConfig?.directControl?.actions || [];
const conditions =
buttonConfig?.dataflowConfig?.directControl?.conditions || [];
return actions.length <= 3 && conditions.length <= 5;
}
/**
* 빠른 검증 실행
*/
async function validateQuickly(
buttonConfig: any,
contextData: Record<string, any>
): Promise<any> {
// 간단한 mock 검증
await new Promise((resolve) => setTimeout(resolve, 10));
return {
success: true,
message: "검증이 완료되었습니다.",
};
}
/**
* 간단한 데이터플로우 실행
*/
async function executeSimpleDataflowAction(
config: any,
contextData: Record<string, any>,
companyCode: string
): Promise<any> {
try {
// 실제로는 EventTriggerService 사용
const result = await EventTriggerService.executeEventTriggers(
"insert", // TODO: 동적으로 결정
"test_table", // TODO: 설정에서 가져오기
contextData,
companyCode
);
return {
success: true,
executedActions: result.length,
message: `${result.length}개의 액션이 실행되었습니다.`,
results: result,
};
} catch (error) {
logger.error("Simple dataflow execution failed:", error);
throw error;
}
}
/**
* 백그라운드에서 데이터플로우 처리 (비동기)
*/
function processDataflowInBackground(
jobId: string,
buttonConfig: any,
contextData: Record<string, any>,
companyCode: string,
priority: string = "normal"
): void {
// 실제로는 작업 큐에 추가
// 여기서는 간단한 setTimeout으로 시뮬레이션
setTimeout(async () => {
try {
logger.info(`Background job started: ${jobId}`);
// 실제 제어관리 로직 실행
const result = await executeSimpleDataflowAction(
buttonConfig.dataflowConfig,
contextData,
companyCode
);
logger.info(`Background job completed: ${jobId}`, result);
// 실제로는 WebSocket이나 polling으로 클라이언트에 알림
} catch (error) {
logger.error(`Background job failed: ${jobId}`, error);
}
}, 1000); // 1초 후 실행 시뮬레이션
}

View File

@@ -0,0 +1,74 @@
/**
* 🔥 버튼 데이터플로우 라우트
*
* 성능 최적화된 API 엔드포인트들
*/
import express from "express";
import {
getButtonDataflowConfig,
updateButtonDataflowConfig,
getAvailableDiagrams,
getDiagramRelationships,
getRelationshipPreview,
executeOptimizedButton,
executeSimpleDataflow,
getJobStatus,
} from "../controllers/buttonDataflowController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// 🔥 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// ============================================================================
// 🔥 버튼 설정 관리
// ============================================================================
// 버튼별 제어관리 설정 조회
router.get("/config/:buttonId", getButtonDataflowConfig);
// 버튼별 제어관리 설정 업데이트
router.put("/config/:buttonId", updateButtonDataflowConfig);
// ============================================================================
// 🔥 관계도 및 관계 정보 조회
// ============================================================================
// 사용 가능한 관계도 목록 조회
router.get("/diagrams", getAvailableDiagrams);
// 특정 관계도의 관계 목록 조회
router.get("/diagrams/:diagramId/relationships", getDiagramRelationships);
// 관계 미리보기 정보 조회
router.get(
"/diagrams/:diagramId/relationships/:relationshipId/preview",
getRelationshipPreview
);
// ============================================================================
// 🔥 버튼 실행 (성능 최적화)
// ============================================================================
// 최적화된 버튼 실행 (즉시 응답 + 백그라운드)
router.post("/execute-optimized", executeOptimizedButton);
// 간단한 데이터플로우 즉시 실행
router.post("/execute-simple", executeSimpleDataflow);
// 백그라운드 작업 상태 조회
router.get("/job-status/:jobId", getJobStatus);
// ============================================================================
// 🔥 레거시 호환성 (기존 API와 호환)
// ============================================================================
// 기존 실행 API (redirect to optimized)
router.post("/execute", executeOptimizedButton);
// 백그라운드 실행 API (실제로는 optimized와 동일)
router.post("/execute-background", executeOptimizedButton);
export default router;

View File

@@ -0,0 +1,89 @@
/**
* 🧪 테스트 전용 버튼 데이터플로우 라우트 (인증 없음)
*
* 개발 환경에서만 사용되는 테스트용 API 엔드포인트
*/
import express from "express";
import {
getButtonDataflowConfig,
updateButtonDataflowConfig,
getAvailableDiagrams,
getDiagramRelationships,
getRelationshipPreview,
executeOptimizedButton,
executeSimpleDataflow,
getJobStatus,
} from "../controllers/buttonDataflowController";
import { AuthenticatedRequest } from "../types/auth";
import config from "../config/environment";
const router = express.Router();
// 🚨 개발 환경에서만 활성화
if (config.nodeEnv !== "production") {
// 테스트용 사용자 정보 설정 미들웨어
const setTestUser = (req: AuthenticatedRequest, res: any, next: any) => {
req.user = {
userId: "test-user",
userName: "Test User",
companyCode: "*",
email: "test@example.com",
};
next();
};
// 모든 라우트에 테스트 사용자 설정
router.use(setTestUser);
// ============================================================================
// 🧪 테스트 전용 API 엔드포인트들
// ============================================================================
// 버튼별 제어관리 설정 조회
router.get("/config/:buttonId", getButtonDataflowConfig);
// 버튼별 제어관리 설정 업데이트
router.put("/config/:buttonId", updateButtonDataflowConfig);
// 사용 가능한 관계도 목록 조회
router.get("/diagrams", getAvailableDiagrams);
// 특정 관계도의 관계 목록 조회
router.get("/diagrams/:diagramId/relationships", getDiagramRelationships);
// 관계 미리보기 정보 조회
router.get(
"/diagrams/:diagramId/relationships/:relationshipId/preview",
getRelationshipPreview
);
// 최적화된 버튼 실행 (즉시 응답 + 백그라운드)
router.post("/execute-optimized", executeOptimizedButton);
// 간단한 데이터플로우 즉시 실행
router.post("/execute-simple", executeSimpleDataflow);
// 백그라운드 작업 상태 조회
router.get("/job-status/:jobId", getJobStatus);
// 테스트 상태 확인 엔드포인트
router.get("/test-status", (req: AuthenticatedRequest, res) => {
res.json({
success: true,
message: "테스트 모드 활성화됨",
user: req.user,
environment: config.nodeEnv,
});
});
} else {
// 운영 환경에서는 접근 차단
router.use((req, res) => {
res.status(403).json({
success: false,
message: "테스트 API는 개발 환경에서만 사용 가능합니다.",
});
});
}
export default router;

View File

@@ -0,0 +1,520 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export interface ControlCondition {
id: string;
type: "condition" | "group-start" | "group-end";
field?: string;
value?: any;
operator?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
dataType?: "string" | "number" | "date" | "boolean";
logicalOperator?: "AND" | "OR";
groupId?: string;
groupLevel?: number;
}
export interface ControlAction {
id: string;
name: string;
actionType: "insert" | "update" | "delete";
conditions: ControlCondition[];
fieldMappings: {
sourceField?: string;
sourceTable?: string;
targetField: string;
targetTable: string;
defaultValue?: any;
}[];
splitConfig?: {
delimiter?: string;
sourceField?: string;
targetField?: string;
};
}
export interface ControlPlan {
id: string;
sourceTable: string;
actions: ControlAction[];
}
export interface ControlRule {
id: string;
triggerType: "insert" | "update" | "delete";
conditions: ControlCondition[];
}
export class DataflowControlService {
/**
* 제어관리 실행 메인 함수
*/
async executeDataflowControl(
diagramId: number,
relationshipId: string,
triggerType: "insert" | "update" | "delete",
sourceData: Record<string, any>,
tableName: string
): Promise<{
success: boolean;
message: string;
executedActions?: any[];
errors?: string[];
}> {
try {
console.log(`🎯 제어관리 실행 시작:`, {
diagramId,
relationshipId,
triggerType,
sourceData,
tableName,
});
// 관계도 정보 조회
const diagram = await prisma.dataflow_diagrams.findUnique({
where: { diagram_id: diagramId },
});
if (!diagram) {
return {
success: false,
message: `관계도를 찾을 수 없습니다. (ID: ${diagramId})`,
};
}
// 제어 규칙과 실행 계획 추출
const controlRules = (diagram.control as unknown as ControlRule[]) || [];
const executionPlans = (diagram.plan as unknown as ControlPlan[]) || [];
console.log(`📋 제어 규칙:`, controlRules);
console.log(`📋 실행 계획:`, executionPlans);
// 해당 관계의 제어 규칙 찾기
const targetRule = controlRules.find(
(rule) => rule.id === relationshipId && rule.triggerType === triggerType
);
if (!targetRule) {
console.log(
`⚠️ 해당 관계의 제어 규칙을 찾을 수 없습니다: ${relationshipId}`
);
return {
success: true,
message: "해당 관계의 제어 규칙이 없습니다.",
};
}
// 제어 조건 검증
const conditionResult = await this.evaluateConditions(
targetRule.conditions,
sourceData
);
console.log(`🔍 조건 검증 결과:`, conditionResult);
if (!conditionResult.satisfied) {
return {
success: true,
message: `제어 조건을 만족하지 않습니다: ${conditionResult.reason}`,
};
}
// 실행 계획 찾기
const targetPlan = executionPlans.find(
(plan) => plan.id === relationshipId
);
if (!targetPlan) {
return {
success: true,
message: "실행할 계획이 없습니다.",
};
}
// 액션 실행
const executedActions = [];
const errors = [];
for (const action of targetPlan.actions) {
try {
console.log(`⚡ 액션 실행: ${action.name} (${action.actionType})`);
// 액션 조건 검증 (있는 경우)
if (action.conditions && action.conditions.length > 0) {
const actionConditionResult = await this.evaluateConditions(
action.conditions,
sourceData
);
if (!actionConditionResult.satisfied) {
console.log(
`⚠️ 액션 조건 미충족: ${actionConditionResult.reason}`
);
continue;
}
}
const actionResult = await this.executeAction(action, sourceData);
executedActions.push({
actionId: action.id,
actionName: action.name,
result: actionResult,
});
} catch (error) {
console.error(`❌ 액션 실행 오류: ${action.name}`, error);
errors.push(
`액션 '${action.name}' 실행 오류: ${error instanceof Error ? error.message : String(error)}`
);
}
}
return {
success: true,
message: `제어관리 실행 완료. ${executedActions.length}개 액션 실행됨.`,
executedActions,
errors: errors.length > 0 ? errors : undefined,
};
} catch (error) {
console.error("❌ 제어관리 실행 오류:", error);
return {
success: false,
message: `제어관리 실행 중 오류 발생: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* 조건 평가
*/
private async evaluateConditions(
conditions: ControlCondition[],
data: Record<string, any>
): Promise<{ satisfied: boolean; reason?: string }> {
if (!conditions || conditions.length === 0) {
return { satisfied: true };
}
try {
// 조건을 SQL WHERE 절로 변환
const whereClause = this.buildWhereClause(conditions, data);
console.log(`🔍 생성된 WHERE 절:`, whereClause);
// 간단한 조건 평가 (실제로는 더 복잡한 로직 필요)
for (const condition of conditions) {
if (condition.type === "condition" && condition.field) {
const fieldValue = data[condition.field];
const conditionValue = condition.value;
console.log(
`🔍 조건 평가: ${condition.field} ${condition.operator} ${conditionValue} (실제값: ${fieldValue})`
);
const result = this.evaluateSingleCondition(
fieldValue,
condition.operator || "=",
conditionValue,
condition.dataType || "string"
);
if (!result) {
return {
satisfied: false,
reason: `조건 미충족: ${condition.field} ${condition.operator} ${conditionValue}`,
};
}
}
}
return { satisfied: true };
} catch (error) {
console.error("조건 평가 오류:", error);
return {
satisfied: false,
reason: `조건 평가 오류: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* 단일 조건 평가
*/
private evaluateSingleCondition(
fieldValue: any,
operator: string,
conditionValue: any,
dataType: string
): boolean {
// 타입 변환
let actualValue = fieldValue;
let expectedValue = conditionValue;
if (dataType === "number") {
actualValue = parseFloat(fieldValue) || 0;
expectedValue = parseFloat(conditionValue) || 0;
} else if (dataType === "string") {
actualValue = String(fieldValue || "");
expectedValue = String(conditionValue || "");
}
// 연산자별 평가
switch (operator) {
case "=":
return actualValue === expectedValue;
case "!=":
return actualValue !== expectedValue;
case ">":
return actualValue > expectedValue;
case "<":
return actualValue < expectedValue;
case ">=":
return actualValue >= expectedValue;
case "<=":
return actualValue <= expectedValue;
case "LIKE":
return String(actualValue).includes(String(expectedValue));
default:
console.warn(`지원되지 않는 연산자: ${operator}`);
return false;
}
}
/**
* WHERE 절 생성 (복잡한 그룹 조건 처리)
*/
private buildWhereClause(
conditions: ControlCondition[],
data: Record<string, any>
): string {
// 실제로는 더 복잡한 그룹 처리 로직이 필요
// 현재는 간단한 AND/OR 처리만 구현
const clauses = [];
for (const condition of conditions) {
if (condition.type === "condition") {
const clause = `${condition.field} ${condition.operator} '${condition.value}'`;
clauses.push(clause);
}
}
return clauses.join(" AND ");
}
/**
* 액션 실행
*/
private async executeAction(
action: ControlAction,
sourceData: Record<string, any>
): Promise<any> {
console.log(`🚀 액션 실행: ${action.actionType}`, action);
switch (action.actionType) {
case "insert":
return await this.executeInsertAction(action, sourceData);
case "update":
return await this.executeUpdateAction(action, sourceData);
case "delete":
return await this.executeDeleteAction(action, sourceData);
default:
throw new Error(`지원되지 않는 액션 타입: ${action.actionType}`);
}
}
/**
* INSERT 액션 실행
*/
private async executeInsertAction(
action: ControlAction,
sourceData: Record<string, any>
): Promise<any> {
const results = [];
for (const mapping of action.fieldMappings) {
const { targetTable, targetField, defaultValue, sourceField } = mapping;
// 삽입할 데이터 준비
const insertData: Record<string, any> = {};
if (sourceField && sourceData[sourceField]) {
insertData[targetField] = sourceData[sourceField];
} else if (defaultValue !== undefined) {
insertData[targetField] = defaultValue;
}
// 동적으로 테이블 컬럼 정보 조회하여 기본 필드 추가
await this.addDefaultFieldsForTable(targetTable, insertData);
console.log(`📝 INSERT 실행: ${targetTable}.${targetField}`, insertData);
try {
// 동적 테이블 INSERT 실행
const result = await prisma.$executeRawUnsafe(
`
INSERT INTO ${targetTable} (${Object.keys(insertData).join(", ")})
VALUES (${Object.keys(insertData)
.map((_, index) => `$${index + 1}`)
.join(", ")})
`,
...Object.values(insertData)
);
results.push({
table: targetTable,
field: targetField,
data: insertData,
result,
});
console.log(`✅ INSERT 성공: ${targetTable}.${targetField}`);
} catch (error) {
console.error(`❌ INSERT 실패: ${targetTable}.${targetField}`, error);
throw error;
}
}
return results;
}
/**
* UPDATE 액션 실행
*/
private async executeUpdateAction(
action: ControlAction,
sourceData: Record<string, any>
): Promise<any> {
// UPDATE 로직 구현
console.log("UPDATE 액션 실행 (미구현)");
return { message: "UPDATE 액션은 아직 구현되지 않았습니다." };
}
/**
* DELETE 액션 실행
*/
private async executeDeleteAction(
action: ControlAction,
sourceData: Record<string, any>
): Promise<any> {
// DELETE 로직 구현
console.log("DELETE 액션 실행 (미구현)");
return { message: "DELETE 액션은 아직 구현되지 않았습니다." };
}
/**
* 테이블의 컬럼 정보를 동적으로 조회하여 기본 필드 추가
*/
private async addDefaultFieldsForTable(
tableName: string,
insertData: Record<string, any>
): Promise<void> {
try {
// 테이블의 컬럼 정보 조회
const columns = await prisma.$queryRawUnsafe<
Array<{ column_name: string; data_type: string; is_nullable: string }>
>(
`
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = $1
ORDER BY ordinal_position
`,
tableName
);
console.log(`📋 ${tableName} 테이블 컬럼 정보:`, columns);
const currentDate = new Date();
// 일반적인 타임스탬프 필드들 확인 및 추가
const timestampFields = [
{
names: ["created_at", "create_date", "reg_date", "regdate"],
value: currentDate,
},
{
names: ["updated_at", "update_date", "mod_date", "moddate"],
value: currentDate,
},
];
for (const fieldGroup of timestampFields) {
for (const fieldName of fieldGroup.names) {
const column = columns.find(
(col) => col.column_name.toLowerCase() === fieldName.toLowerCase()
);
if (column && !insertData[column.column_name]) {
// 해당 컬럼이 존재하고 아직 값이 설정되지 않은 경우
if (
column.data_type.includes("timestamp") ||
column.data_type.includes("date")
) {
insertData[column.column_name] = fieldGroup.value;
console.log(
`📅 기본 타임스탬프 필드 추가: ${column.column_name} = ${fieldGroup.value}`
);
}
}
}
}
// 필수 필드 중 값이 없는 경우 기본값 설정
for (const column of columns) {
if (column.is_nullable === "NO" && !insertData[column.column_name]) {
// NOT NULL 필드인데 값이 없는 경우 기본값 설정
const defaultValue = this.getDefaultValueForColumn(column);
if (defaultValue !== null) {
insertData[column.column_name] = defaultValue;
console.log(
`🔧 필수 필드 기본값 설정: ${column.column_name} = ${defaultValue}`
);
}
}
}
} catch (error) {
console.error(`${tableName} 테이블 컬럼 정보 조회 실패:`, error);
// 에러가 발생해도 INSERT는 계속 진행 (기본 필드 없이)
}
}
/**
* 컬럼 타입에 따른 기본값 반환
*/
private getDefaultValueForColumn(column: {
column_name: string;
data_type: string;
}): any {
const dataType = column.data_type.toLowerCase();
const columnName = column.column_name.toLowerCase();
// 컬럼명 기반 기본값
if (columnName.includes("status")) {
return "Y"; // 상태 필드는 보통 'Y'
}
if (columnName.includes("type")) {
return "default"; // 타입 필드는 'default'
}
// 데이터 타입 기반 기본값
if (
dataType.includes("varchar") ||
dataType.includes("text") ||
dataType.includes("char")
) {
return ""; // 문자열은 빈 문자열
}
if (
dataType.includes("int") ||
dataType.includes("numeric") ||
dataType.includes("decimal")
) {
return 0; // 숫자는 0
}
if (dataType.includes("bool")) {
return false; // 불린은 false
}
if (dataType.includes("timestamp") || dataType.includes("date")) {
return new Date(); // 날짜는 현재 시간
}
return null; // 기본값을 설정할 수 없는 경우
}
}

View File

@@ -1,6 +1,7 @@
import prisma from "../config/database";
import { Prisma } from "@prisma/client";
import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService";
export interface FormDataResult {
id: number;
@@ -42,6 +43,71 @@ export interface TableColumn {
}
export class DynamicFormService {
private dataflowControlService = new DataflowControlService();
/**
* 값을 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);
}
// 기본적으로 문자열로 반환
return value;
}
/**
* 테이블의 컬럼 정보 조회 (타입 포함)
*/
private async getTableColumnInfo(
tableName: string
): Promise<Array<{ column_name: string; data_type: string }>> {
try {
const result = await prisma.$queryRaw<
Array<{ column_name: string; data_type: string }>
>`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = ${tableName}
AND table_schema = 'public'
`;
return result;
} catch (error) {
console.error(`테이블 ${tableName}의 컬럼 정보 조회 실패:`, error);
return [];
}
}
/**
* 테이블의 컬럼명 목록 조회 (간단 버전)
*/
@@ -196,6 +262,32 @@ export class DynamicFormService {
dataToInsert,
});
// 테이블 컬럼 정보 조회하여 타입 변환 적용
console.log("🔍 테이블 컬럼 정보 조회 중...");
const columnInfo = await this.getTableColumnInfo(tableName);
console.log("📊 테이블 컬럼 정보:", columnInfo);
// 각 컬럼의 타입에 맞게 데이터 변환
Object.keys(dataToInsert).forEach((columnName) => {
const column = columnInfo.find((col) => col.column_name === columnName);
if (column) {
const originalValue = dataToInsert[columnName];
const convertedValue = this.convertValueForPostgreSQL(
originalValue,
column.data_type
);
if (originalValue !== convertedValue) {
console.log(
`🔄 타입 변환: ${columnName} (${column.data_type}) = "${originalValue}" -> ${convertedValue}`
);
dataToInsert[columnName] = convertedValue;
}
}
});
console.log("✅ 타입 변환 완료된 데이터:", dataToInsert);
// 동적 SQL을 사용하여 실제 테이블에 UPSERT
const columns = Object.keys(dataToInsert);
const values: any[] = Object.values(dataToInsert);
@@ -264,6 +356,19 @@ export class DynamicFormService {
// 트리거 오류는 로그만 남기고 메인 저장 프로세스는 계속 진행
}
// 🎯 제어관리 실행 (새로 추가)
try {
await this.executeDataflowControlIfConfigured(
screenId,
tableName,
insertedRecord as Record<string, any>,
"insert"
);
} catch (controlError) {
console.error("⚠️ 제어관리 실행 오류:", controlError);
// 제어관리 오류는 로그만 남기고 메인 저장 프로세스는 계속 진행
}
return {
id: insertedRecord.id || insertedRecord.objid || 0,
screenId: screenId,
@@ -674,6 +779,85 @@ export class DynamicFormService {
throw new Error(`테이블 컬럼 정보 조회 실패: ${error}`);
}
}
/**
* 제어관리 실행 (화면에 설정된 경우)
*/
private async executeDataflowControlIfConfigured(
screenId: number,
tableName: string,
savedData: Record<string, any>,
triggerType: "insert" | "update" | "delete"
): Promise<void> {
try {
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
// 화면의 저장 버튼에서 제어관리 설정 조회
const screenLayouts = await prisma.screen_layouts.findMany({
where: {
screen_id: screenId,
component_type: "component",
},
});
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
// 저장 버튼 중에서 제어관리가 활성화된 것 찾기
for (const layout of screenLayouts) {
const properties = layout.properties as any;
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
if (
properties?.componentType === "button-primary" &&
properties?.componentConfig?.action?.type === "save" &&
properties?.webTypeConfig?.enableDataflowControl === true &&
properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId
) {
const diagramId =
properties.webTypeConfig.dataflowConfig.selectedDiagramId;
const relationshipId =
properties.webTypeConfig.dataflowConfig.selectedRelationshipId;
console.log(`🎯 제어관리 설정 발견:`, {
componentId: layout.component_id,
diagramId,
relationshipId,
triggerType,
});
// 제어관리 실행
const controlResult =
await this.dataflowControlService.executeDataflowControl(
diagramId,
relationshipId,
triggerType,
savedData,
tableName
);
console.log(`🎯 제어관리 실행 결과:`, controlResult);
if (controlResult.success) {
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
if (
controlResult.executedActions &&
controlResult.executedActions.length > 0
) {
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
}
} else {
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
}
// 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우)
break;
}
}
} catch (error) {
console.error("❌ 제어관리 설정 확인 및 실행 오류:", error);
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
}
}
}
// 싱글톤 인스턴스 생성 및 export