/** * 리포트 관리 서비스 */ import { v4 as uuidv4 } from "uuid"; import { query, queryOne, transaction } from "../database/db"; import { ReportMaster, ReportLayout, ReportQuery, ReportTemplate, ReportDetail, GetReportsParams, GetReportsResponse, CreateReportRequest, UpdateReportRequest, SaveLayoutRequest, GetTemplatesResponse, CreateTemplateRequest, } from "../types/report"; import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; import { ExternalDbConnectionService } from "./externalDbConnectionService"; export class ReportService { /** * SQL 쿼리 검증 (SELECT만 허용) */ private validateQuerySafety(sql: string): void { // 위험한 SQL 명령어 목록 const dangerousKeywords = [ "DELETE", "DROP", "TRUNCATE", "INSERT", "UPDATE", "ALTER", "CREATE", "REPLACE", "MERGE", "GRANT", "REVOKE", "EXECUTE", "EXEC", "CALL", ]; // SQL을 대문자로 변환하여 검사 const upperSql = sql.toUpperCase().trim(); // 위험한 키워드 검사 for (const keyword of dangerousKeywords) { // 단어 경계를 고려하여 검사 (예: DELETE, DELETE FROM 등) const regex = new RegExp(`\\b${keyword}\\b`, "i"); if (regex.test(upperSql)) { throw new Error( `보안상의 이유로 ${keyword} 명령어는 사용할 수 없습니다. SELECT 쿼리만 허용됩니다.` ); } } // SELECT 쿼리인지 확인 if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) { throw new Error( "SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다." ); } // 세미콜론으로 구분된 여러 쿼리 방지 const semicolonCount = (sql.match(/;/g) || []).length; if ( semicolonCount > 1 || (semicolonCount === 1 && !sql.trim().endsWith(";")) ) { throw new Error( "보안상의 이유로 여러 개의 쿼리를 동시에 실행할 수 없습니다." ); } } /** * 리포트 목록 조회 */ async getReports(params: GetReportsParams): Promise { const { page = 1, limit = 20, searchText = "", reportType = "", useYn = "Y", sortBy = "created_at", sortOrder = "DESC", } = params; const offset = (page - 1) * limit; // WHERE 조건 동적 생성 const conditions: string[] = []; const values: any[] = []; let paramIndex = 1; if (useYn) { conditions.push(`use_yn = $${paramIndex++}`); values.push(useYn); } if (searchText) { conditions.push( `(report_name_kor LIKE $${paramIndex} OR report_name_eng LIKE $${paramIndex})` ); values.push(`%${searchText}%`); paramIndex++; } if (reportType) { conditions.push(`report_type = $${paramIndex++}`); values.push(reportType); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; // 전체 개수 조회 const countQuery = ` SELECT COUNT(*) as total FROM report_master ${whereClause} `; const countResult = await queryOne<{ total: string }>(countQuery, values); const total = parseInt(countResult?.total || "0", 10); // 목록 조회 const listQuery = ` SELECT report_id, report_name_kor, report_name_eng, template_id, report_type, company_code, description, use_yn, created_at, created_by, updated_at, updated_by FROM report_master ${whereClause} ORDER BY ${sortBy} ${sortOrder} LIMIT $${paramIndex++} OFFSET $${paramIndex} `; const items = await query(listQuery, [ ...values, limit, offset, ]); return { items, total, page, limit, }; } /** * 리포트 상세 조회 */ async getReportById(reportId: string): Promise { // 리포트 마스터 조회 const reportQuery = ` SELECT report_id, report_name_kor, report_name_eng, template_id, report_type, company_code, description, use_yn, created_at, created_by, updated_at, updated_by FROM report_master WHERE report_id = $1 `; const report = await queryOne(reportQuery, [reportId]); if (!report) { return null; } // 레이아웃 조회 const layoutQuery = ` SELECT layout_id, report_id, canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components, created_at, created_by, updated_at, updated_by FROM report_layout WHERE report_id = $1 `; const layout = await queryOne(layoutQuery, [reportId]); // 쿼리 조회 const queriesQuery = ` SELECT query_id, report_id, query_name, query_type, sql_query, parameters, external_connection_id, display_order, created_at, created_by, updated_at, updated_by FROM report_query WHERE report_id = $1 ORDER BY display_order, created_at `; const queries = await query(queriesQuery, [reportId]); return { report, layout, queries: queries || [], }; } /** * 리포트 생성 */ async createReport( data: CreateReportRequest, userId: string ): Promise { const reportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; return transaction(async (client) => { // 리포트 마스터 생성 const insertReportQuery = ` INSERT INTO report_master ( report_id, report_name_kor, report_name_eng, template_id, report_type, company_code, description, use_yn, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', $8) `; await client.query(insertReportQuery, [ reportId, data.reportNameKor, data.reportNameEng || null, data.templateId || null, data.reportType, data.companyCode || null, data.description || null, userId, ]); // 템플릿이 있으면 해당 템플릿의 레이아웃 복사 if (data.templateId) { const templateQuery = ` SELECT layout_config FROM report_template WHERE template_id = $1 `; const template = await client.query(templateQuery, [data.templateId]); if (template.rows.length > 0 && template.rows[0].layout_config) { const layoutConfig = JSON.parse(template.rows[0].layout_config); const layoutId = `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const insertLayoutQuery = ` INSERT INTO report_layout ( layout_id, report_id, canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `; await client.query(insertLayoutQuery, [ layoutId, reportId, layoutConfig.width || 210, layoutConfig.height || 297, layoutConfig.orientation || "portrait", 20, 20, 20, 20, JSON.stringify([]), userId, ]); } } return reportId; }); } /** * 리포트 수정 */ async updateReport( reportId: string, data: UpdateReportRequest, userId: string ): Promise { const setClauses: string[] = []; const values: any[] = []; let paramIndex = 1; if (data.reportNameKor !== undefined) { setClauses.push(`report_name_kor = $${paramIndex++}`); values.push(data.reportNameKor); } if (data.reportNameEng !== undefined) { setClauses.push(`report_name_eng = $${paramIndex++}`); values.push(data.reportNameEng); } if (data.reportType !== undefined) { setClauses.push(`report_type = $${paramIndex++}`); values.push(data.reportType); } if (data.description !== undefined) { setClauses.push(`description = $${paramIndex++}`); values.push(data.description); } if (data.useYn !== undefined) { setClauses.push(`use_yn = $${paramIndex++}`); values.push(data.useYn); } if (setClauses.length === 0) { return false; } setClauses.push(`updated_at = CURRENT_TIMESTAMP`); setClauses.push(`updated_by = $${paramIndex++}`); values.push(userId); values.push(reportId); const updateQuery = ` UPDATE report_master SET ${setClauses.join(", ")} WHERE report_id = $${paramIndex} `; const result = await query(updateQuery, values); return true; } /** * 리포트 삭제 */ async deleteReport(reportId: string): Promise { return transaction(async (client) => { // 쿼리 삭제 (CASCADE로 자동 삭제되지만 명시적으로) await client.query(`DELETE FROM report_query WHERE report_id = $1`, [ reportId, ]); // 레이아웃 삭제 await client.query(`DELETE FROM report_layout WHERE report_id = $1`, [ reportId, ]); // 리포트 마스터 삭제 const result = await client.query( `DELETE FROM report_master WHERE report_id = $1`, [reportId] ); return (result.rowCount ?? 0) > 0; }); } /** * 리포트 복사 */ async copyReport(reportId: string, userId: string): Promise { return transaction(async (client) => { // 원본 리포트 조회 const originalQuery = ` SELECT * FROM report_master WHERE report_id = $1 `; const originalResult = await client.query(originalQuery, [reportId]); if (originalResult.rows.length === 0) { return null; } const original = originalResult.rows[0]; const newReportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; // 리포트 마스터 복사 const copyReportQuery = ` INSERT INTO report_master ( report_id, report_name_kor, report_name_eng, template_id, report_type, company_code, description, use_yn, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `; await client.query(copyReportQuery, [ newReportId, `${original.report_name_kor} (복사)`, original.report_name_eng ? `${original.report_name_eng} (Copy)` : null, original.template_id, original.report_type, original.company_code, original.description, original.use_yn, userId, ]); // 레이아웃 복사 const layoutQuery = ` SELECT * FROM report_layout WHERE report_id = $1 `; const layoutResult = await client.query(layoutQuery, [reportId]); if (layoutResult.rows.length > 0) { const originalLayout = layoutResult.rows[0]; const newLayoutId = `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const copyLayoutQuery = ` INSERT INTO report_layout ( layout_id, report_id, canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `; await client.query(copyLayoutQuery, [ newLayoutId, newReportId, originalLayout.canvas_width, originalLayout.canvas_height, originalLayout.page_orientation, originalLayout.margin_top, originalLayout.margin_bottom, originalLayout.margin_left, originalLayout.margin_right, JSON.stringify(originalLayout.components), userId, ]); } // 쿼리 복사 const queriesQuery = ` SELECT * FROM report_query WHERE report_id = $1 ORDER BY display_order `; const queriesResult = await client.query(queriesQuery, [reportId]); if (queriesResult.rows.length > 0) { const copyQuerySql = ` INSERT INTO report_query ( query_id, report_id, query_name, query_type, sql_query, parameters, external_connection_id, display_order, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `; for (const originalQuery of queriesResult.rows) { const newQueryId = `QRY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; await client.query(copyQuerySql, [ newQueryId, newReportId, originalQuery.query_name, originalQuery.query_type, originalQuery.sql_query, JSON.stringify(originalQuery.parameters), originalQuery.external_connection_id || null, originalQuery.display_order, userId, ]); } } return newReportId; }); } /** * 레이아웃 조회 */ async getLayout(reportId: string): Promise { const layoutQuery = ` SELECT layout_id, report_id, canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components, created_at, created_by, updated_at, updated_by FROM report_layout WHERE report_id = $1 `; return queryOne(layoutQuery, [reportId]); } /** * 레이아웃 저장 (쿼리 포함) */ async saveLayout( reportId: string, data: SaveLayoutRequest, userId: string ): Promise { return transaction(async (client) => { // 1. 레이아웃 저장 const existingQuery = ` SELECT layout_id FROM report_layout WHERE report_id = $1 `; const existing = await client.query(existingQuery, [reportId]); if (existing.rows.length > 0) { // 업데이트 const updateQuery = ` UPDATE report_layout SET canvas_width = $1, canvas_height = $2, page_orientation = $3, margin_top = $4, margin_bottom = $5, margin_left = $6, margin_right = $7, components = $8, updated_at = CURRENT_TIMESTAMP, updated_by = $9 WHERE report_id = $10 `; await client.query(updateQuery, [ data.canvasWidth, data.canvasHeight, data.pageOrientation, data.marginTop, data.marginBottom, data.marginLeft, data.marginRight, JSON.stringify(data.components), userId, reportId, ]); } else { // 생성 const layoutId = `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const insertQuery = ` INSERT INTO report_layout ( layout_id, report_id, canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `; await client.query(insertQuery, [ layoutId, reportId, data.canvasWidth, data.canvasHeight, data.pageOrientation, data.marginTop, data.marginBottom, data.marginLeft, data.marginRight, JSON.stringify(data.components), userId, ]); } // 2. 쿼리 저장 (있는 경우) if (data.queries && data.queries.length > 0) { // 기존 쿼리 모두 삭제 await client.query(`DELETE FROM report_query WHERE report_id = $1`, [ reportId, ]); // 새 쿼리 삽입 const insertQuerySql = ` INSERT INTO report_query ( query_id, report_id, query_name, query_type, sql_query, parameters, external_connection_id, display_order, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `; for (let i = 0; i < data.queries.length; i++) { const q = data.queries[i]; await client.query(insertQuerySql, [ q.id, reportId, q.name, q.type, q.sqlQuery, JSON.stringify(q.parameters), (q as any).externalConnectionId || null, // 외부 DB 연결 ID i, userId, ]); } } return true; }); } /** * 쿼리 실행 (내부 DB 또는 외부 DB) */ async executeQuery( reportId: string, queryId: string, parameters: Record, sqlQuery?: string, externalConnectionId?: number | null ): Promise<{ fields: string[]; rows: any[] }> { let sql_query: string; let queryParameters: string[] = []; let connectionId: number | null = externalConnectionId ?? null; // 테스트 모드 (sqlQuery 직접 전달) if (sqlQuery) { sql_query = sqlQuery; // 파라미터 순서 추출 (등장 순서대로) const matches = sqlQuery.match(/\$\d+/g); if (matches) { const seen = new Set(); const result: string[] = []; for (const match of matches) { if (!seen.has(match)) { seen.add(match); result.push(match); } } queryParameters = result; } } else { // DB에서 쿼리 조회 const queryResult = await queryOne( `SELECT * FROM report_query WHERE query_id = $1 AND report_id = $2`, [queryId, reportId] ); if (!queryResult) { throw new Error("쿼리를 찾을 수 없습니다."); } sql_query = queryResult.sql_query; queryParameters = Array.isArray(queryResult.parameters) ? queryResult.parameters : []; connectionId = queryResult.external_connection_id; } // SQL 쿼리 안전성 검증 (SELECT만 허용) this.validateQuerySafety(sql_query); // 파라미터 배열 생성 ($1, $2 순서대로) const paramArray: any[] = []; for (const param of queryParameters) { paramArray.push(parameters[param] || null); } try { let result: any[]; // 외부 DB 연결이 있으면 외부 DB에서 실행 if (connectionId) { // 외부 DB 연결 정보 조회 const connectionResult = await ExternalDbConnectionService.getConnectionById(connectionId); if (!connectionResult.success || !connectionResult.data) { throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } const connection = connectionResult.data; // DatabaseConnectorFactory를 사용하여 외부 DB 쿼리 실행 const config = { host: connection.host, port: connection.port, database: connection.database_name, user: connection.username, password: connection.password, connectionTimeout: connection.connection_timeout || 30000, queryTimeout: connection.query_timeout || 30000, }; const connector = await DatabaseConnectorFactory.createConnector( connection.db_type, config, connectionId ); await connector.connect(); try { const queryResult = await connector.executeQuery(sql_query); result = queryResult.rows || []; } finally { await connector.disconnect(); } } else { // 내부 DB에서 실행 result = await query(sql_query, paramArray); } // 필드명 추출 const fields = result.length > 0 ? Object.keys(result[0]) : []; return { fields, rows: result, }; } catch (error: any) { throw new Error(`쿼리 실행 오류: ${error.message}`); } } /** * 템플릿 목록 조회 */ async getTemplates(): Promise { const templateQuery = ` SELECT template_id, template_name_kor, template_name_eng, template_type, is_system, thumbnail_url, description, layout_config, default_queries, use_yn, sort_order, created_at, created_by, updated_at, updated_by FROM report_template WHERE use_yn = 'Y' ORDER BY is_system DESC, sort_order ASC `; const templates = await query(templateQuery); const system = templates.filter((t) => t.is_system === "Y"); const custom = templates.filter((t) => t.is_system === "N"); return { system, custom }; } /** * 템플릿 생성 (사용자 정의) */ async createTemplate( data: CreateTemplateRequest, userId: string ): Promise { const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const insertQuery = ` INSERT INTO report_template ( template_id, template_name_kor, template_name_eng, template_type, is_system, description, layout_config, default_queries, use_yn, created_by ) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', $8) `; await query(insertQuery, [ templateId, data.templateNameKor, data.templateNameEng || null, data.templateType, data.description || null, data.layoutConfig ? JSON.stringify(data.layoutConfig) : null, data.defaultQueries ? JSON.stringify(data.defaultQueries) : null, userId, ]); return templateId; } /** * 템플릿 삭제 (사용자 정의만 가능) */ async deleteTemplate(templateId: string): Promise { const deleteQuery = ` DELETE FROM report_template WHERE template_id = $1 AND is_system = 'N' `; const result = await query(deleteQuery, [templateId]); return true; } /** * 현재 리포트를 템플릿으로 저장 */ async saveAsTemplate( reportId: string, templateNameKor: string, templateNameEng: string | null | undefined, description: string | null | undefined, userId: string ): Promise { return transaction(async (client) => { // 리포트 정보 조회 const reportQuery = ` SELECT report_type FROM report_master WHERE report_id = $1 `; const reportResult = await client.query(reportQuery, [reportId]); if (reportResult.rows.length === 0) { throw new Error("리포트를 찾을 수 없습니다."); } const reportType = reportResult.rows[0].report_type; // 레이아웃 조회 const layoutQuery = ` SELECT canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components FROM report_layout WHERE report_id = $1 `; const layoutResult = await client.query(layoutQuery, [reportId]); if (layoutResult.rows.length === 0) { throw new Error("레이아웃을 찾을 수 없습니다."); } const layout = layoutResult.rows[0]; // 쿼리 조회 const queriesQuery = ` SELECT query_name, query_type, sql_query, parameters, external_connection_id, display_order FROM report_query WHERE report_id = $1 ORDER BY display_order `; const queriesResult = await client.query(queriesQuery, [reportId]); // 레이아웃 설정 JSON 생성 const layoutConfig = { width: layout.canvas_width, height: layout.canvas_height, orientation: layout.page_orientation, margins: { top: layout.margin_top, bottom: layout.margin_bottom, left: layout.margin_left, right: layout.margin_right, }, components: JSON.parse(layout.components || "[]"), }; // 기본 쿼리 JSON 생성 const defaultQueries = queriesResult.rows.map((q) => ({ name: q.query_name, type: q.query_type, sqlQuery: q.sql_query, parameters: Array.isArray(q.parameters) ? q.parameters : [], externalConnectionId: q.external_connection_id, displayOrder: q.display_order, })); // 템플릿 생성 const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const insertQuery = ` INSERT INTO report_template ( template_id, template_name_kor, template_name_eng, template_type, is_system, description, layout_config, default_queries, use_yn, sort_order, created_by ) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', 999, $8) `; await client.query(insertQuery, [ templateId, templateNameKor, templateNameEng || null, reportType, description || null, JSON.stringify(layoutConfig), JSON.stringify(defaultQueries), userId, ]); return templateId; }); } // 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요) async createTemplateFromLayout( templateNameKor: string, templateNameEng: string | null | undefined, templateType: string, description: string | null | undefined, layoutConfig: { width: number; height: number; orientation: string; margins: { top: number; bottom: number; left: number; right: number; }; components: any[]; }, defaultQueries: Array<{ name: string; type: "MASTER" | "DETAIL"; sqlQuery: string; parameters: string[]; externalConnectionId?: number | null; displayOrder?: number; }>, userId: string ): Promise { const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const insertQuery = ` INSERT INTO report_template ( template_id, template_name_kor, template_name_eng, template_type, is_system, description, layout_config, default_queries, use_yn, sort_order, created_by ) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', 999, $8) RETURNING template_id `; await query(insertQuery, [ templateId, templateNameKor, templateNameEng || null, templateType, description || null, JSON.stringify(layoutConfig), JSON.stringify(defaultQueries), userId, ]); return templateId; } } export default new ReportService();