/** * 동적 데이터 서비스 * * 주요 특징: * 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능 * 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지 * 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용 * 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증 * * 보안: * - 테이블명은 영문, 숫자, 언더스코어만 허용 * - 시스템 테이블(pg_*, information_schema 등) 접근 금지 * - company_code 컬럼이 있는 테이블은 자동으로 회사별 격리 * - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능 */ import { query, queryOne } from "../database/db"; import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸 import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성 interface GetTableDataParams { tableName: string; limit?: number; offset?: number; orderBy?: string; filters?: Record; userCompany?: string; } interface ServiceResponse { success: boolean; data?: T; message?: string; error?: string; } /** * 접근 금지 테이블 목록 (블랙리스트) * 시스템 중요 테이블 및 보안상 접근 금지할 테이블 */ const BLOCKED_TABLES = [ "pg_catalog", "pg_statistic", "pg_database", "pg_user", "information_schema", "session_tokens", // 세션 토큰 테이블 "password_history", // 패스워드 이력 ]; /** * 테이블 이름 검증 정규식 * SQL 인젝션 방지: 영문, 숫자, 언더스코어만 허용 */ const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/; class DataService { /** * 중복 데이터 제거 (메모리 내 처리) */ private deduplicateData( data: any[], config: { groupByColumn: string; keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; sortColumn?: string; } ): any[] { if (!data || data.length === 0) return data; // 그룹별로 데이터 분류 const groups: Record = {}; for (const row of data) { const groupKey = row[config.groupByColumn]; if (groupKey === undefined || groupKey === null) continue; if (!groups[groupKey]) { groups[groupKey] = []; } groups[groupKey].push(row); } // 각 그룹에서 하나의 행만 선택 const result: any[] = []; for (const [groupKey, rows] of Object.entries(groups)) { if (rows.length === 0) continue; let selectedRow: any; switch (config.keepStrategy) { case "latest": // 정렬 컬럼 기준 최신 (가장 큰 값) if (config.sortColumn) { rows.sort((a, b) => { const aVal = a[config.sortColumn!]; const bVal = b[config.sortColumn!]; if (aVal === bVal) return 0; if (aVal > bVal) return -1; return 1; }); } selectedRow = rows[0]; break; case "earliest": // 정렬 컬럼 기준 최초 (가장 작은 값) if (config.sortColumn) { rows.sort((a, b) => { const aVal = a[config.sortColumn!]; const bVal = b[config.sortColumn!]; if (aVal === bVal) return 0; if (aVal < bVal) return -1; return 1; }); } selectedRow = rows[0]; break; case "base_price": // base_price = true인 행 찾기 selectedRow = rows.find((row) => row.base_price === true) || rows[0]; break; case "current_date": // start_date <= CURRENT_DATE <= end_date 조건에 맞는 행 const today = new Date(); today.setHours(0, 0, 0, 0); // 시간 제거 selectedRow = rows.find((row) => { const startDate = row.start_date ? new Date(row.start_date) : null; const endDate = row.end_date ? new Date(row.end_date) : null; if (startDate) startDate.setHours(0, 0, 0, 0); if (endDate) endDate.setHours(0, 0, 0, 0); const afterStart = !startDate || today >= startDate; const beforeEnd = !endDate || today <= endDate; return afterStart && beforeEnd; }) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행 break; default: selectedRow = rows[0]; } result.push(selectedRow); } return result; } /** * 테이블 접근 검증 (공통 메서드) */ private async validateTableAccess( tableName: string ): Promise<{ valid: boolean; error?: ServiceResponse }> { // 1. 테이블명 형식 검증 (SQL 인젝션 방지) if (!TABLE_NAME_REGEX.test(tableName)) { return { valid: false, error: { success: false, message: `유효하지 않은 테이블명입니다: ${tableName}`, error: "INVALID_TABLE_NAME", }, }; } // 2. 블랙리스트 검증 if (BLOCKED_TABLES.includes(tableName)) { return { valid: false, error: { success: false, message: `접근이 금지된 테이블입니다: ${tableName}`, error: "TABLE_ACCESS_DENIED", }, }; } // 3. 테이블 존재 여부 확인 const tableExists = await this.checkTableExists(tableName); if (!tableExists) { return { valid: false, error: { success: false, message: `테이블을 찾을 수 없습니다: ${tableName}`, error: "TABLE_NOT_FOUND", }, }; } return { valid: true }; } /** * 테이블 데이터 조회 */ async getTableData( params: GetTableDataParams ): Promise> { const { tableName, limit = 10, offset = 0, orderBy, filters = {}, userCompany, } = params; try { // 테이블 접근 검증 const validation = await this.validateTableAccess(tableName); if (!validation.valid) { return validation.error!; } // 동적 SQL 쿼리 생성 let sql = `SELECT * FROM "${tableName}"`; const queryParams: any[] = []; let paramIndex = 1; // WHERE 조건 생성 const whereConditions: string[] = []; // 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우) if (userCompany && userCompany !== "*") { const hasCompanyCode = await this.checkColumnExists( tableName, "company_code" ); if (hasCompanyCode) { whereConditions.push(`company_code = $${paramIndex}`); queryParams.push(userCompany); paramIndex++; console.log( `🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}` ); } } // 사용자 정의 필터 추가 for (const [key, value] of Object.entries(filters)) { if ( value && key !== "limit" && key !== "offset" && key !== "orderBy" && key !== "userLang" ) { // 컬럼명 검증 (SQL 인젝션 방지) if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { continue; // 유효하지 않은 컬럼명은 무시 } whereConditions.push(`"${key}" ILIKE $${paramIndex}`); queryParams.push(`%${value}%`); paramIndex++; } } // WHERE 절 추가 if (whereConditions.length > 0) { sql += ` WHERE ${whereConditions.join(" AND ")}`; } // ORDER BY 절 추가 if (orderBy) { // ORDER BY 검증 (SQL 인젝션 방지) const orderParts = orderBy.split(" "); const columnName = orderParts[0]; const direction = orderParts[1]?.toUpperCase(); if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) { const validDirection = direction === "DESC" ? "DESC" : "ASC"; sql += ` ORDER BY "${columnName}" ${validDirection}`; } } else { // 기본 정렬: 최신순 (가능한 컬럼 시도) const dateColumns = [ "created_date", "regdate", "reg_date", "updated_date", "upd_date", ]; const tableColumns = await this.getTableColumnsSimple(tableName); const availableDateColumn = dateColumns.find((col) => tableColumns.some((tableCol) => tableCol.column_name === col) ); if (availableDateColumn) { sql += ` ORDER BY "${availableDateColumn}" DESC`; } } // LIMIT과 OFFSET 추가 sql += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`; queryParams.push(limit, offset); console.log("🔍 실행할 쿼리:", sql); console.log("📊 쿼리 파라미터:", queryParams); // 쿼리 실행 const result = await query(sql, queryParams); return { success: true, data: result, }; } catch (error) { console.error(`데이터 조회 오류 (${tableName}):`, error); return { success: false, message: "데이터 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }; } } /** * 테이블 컬럼 정보 조회 */ async getTableColumns(tableName: string): Promise> { try { // 테이블 접근 검증 const validation = await this.validateTableAccess(tableName); if (!validation.valid) { return validation.error!; } const columns = await this.getTableColumnsSimple(tableName); // PK 컬럼 정보 조회 const pkColumns = await this.getPrimaryKeyColumns(tableName); // 컬럼 라벨 정보 추가 const columnsWithLabels = await Promise.all( columns.map(async (column) => { const label = await this.getColumnLabel( tableName, column.column_name ); return { columnName: column.column_name, columnLabel: label || column.column_name, dataType: column.data_type, isNullable: column.is_nullable === "YES", defaultValue: column.column_default, isPrimaryKey: pkColumns.includes(column.column_name), // PK 여부 추가 }; }) ); return { success: true, data: columnsWithLabels, }; } catch (error) { console.error(`컬럼 정보 조회 오류 (${tableName}):`, error); return { success: false, message: "컬럼 정보 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }; } } /** * 테이블의 Primary Key 컬럼 목록 조회 */ private async getPrimaryKeyColumns(tableName: string): Promise { try { const result = await query<{ attname: string }>( `SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`, [tableName] ); return result.map((row) => row.attname); } catch (error) { console.error(`PK 컬럼 조회 오류 (${tableName}):`, error); return []; } } /** * 테이블 존재 여부 확인 */ private async checkTableExists(tableName: string): Promise { try { const result = await query<{ exists: boolean }>( `SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 )`, [tableName] ); return result[0]?.exists || false; } catch (error) { console.error("테이블 존재 확인 오류:", error); return false; } } /** * 특정 컬럼 존재 여부 확인 */ async checkColumnExists( tableName: string, columnName: string ): Promise { try { const result = await query<{ exists: boolean }>( `SELECT EXISTS ( SELECT FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2 )`, [tableName, columnName] ); return result[0]?.exists || false; } catch (error) { console.error("컬럼 존재 확인 오류:", error); return false; } } /** * 테이블 컬럼 정보 조회 (간단 버전) */ private async getTableColumnsSimple(tableName: string): Promise { const result = await query( `SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' ORDER BY ordinal_position`, [tableName] ); return result; } /** * 컬럼 라벨 조회 */ private async getColumnLabel( tableName: string, columnName: string ): Promise { try { // column_labels 테이블에서 라벨 조회 const result = await query<{ label_ko: string }>( `SELECT label_ko FROM column_labels WHERE table_name = $1 AND column_name = $2 LIMIT 1`, [tableName, columnName] ); return result[0]?.label_ko || null; } catch (error) { // column_labels 테이블이 없거나 오류가 발생하면 null 반환 return null; } } /** * 레코드 상세 조회 (Entity Join 지원 + 그룹핑 기반 다중 레코드 조회) */ async getRecordDetail( tableName: string, id: string | number, enableEntityJoin: boolean = false, groupByColumns: string[] = [] ): Promise> { try { // 테이블 접근 검증 const validation = await this.validateTableAccess(tableName); if (!validation.valid) { return validation.error!; } // Primary Key 컬럼 찾기 const pkResult = await query<{ attname: string }>( `SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`, [tableName] ); let pkColumn = "id"; // 기본값 if (pkResult.length > 0) { pkColumn = pkResult[0].attname; } // 🆕 Entity Join이 활성화된 경우 if (enableEntityJoin) { const { EntityJoinService } = await import("./entityJoinService"); const entityJoinService = new EntityJoinService(); // Entity Join 구성 감지 const joinConfigs = await entityJoinService.detectEntityJoins(tableName); if (joinConfigs.length > 0) { console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`); // Entity Join 쿼리 생성 (개별 파라미터로 전달) const { query: joinQuery } = entityJoinService.buildJoinQuery( tableName, joinConfigs, ["*"], `main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결 ); const result = await pool.query(joinQuery, [id]); if (result.rows.length === 0) { return { success: false, message: "레코드를 찾을 수 없습니다.", error: "RECORD_NOT_FOUND", }; } // 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환 const normalizeDates = (rows: any[]) => { return rows.map((row) => { const normalized: any = {}; for (const [key, value] of Object.entries(row)) { if (value instanceof Date) { // Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시) const year = value.getFullYear(); const month = String(value.getMonth() + 1).padStart(2, "0"); const day = String(value.getDate()).padStart(2, "0"); normalized[key] = `${year}-${month}-${day}`; } else { normalized[key] = value; } } return normalized; }); }; const normalizedRows = normalizeDates(result.rows); console.log( `✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0] ); // 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회 if (groupByColumns.length > 0) { const baseRecord = result.rows[0]; // 그룹핑 컬럼들의 값 추출 const groupConditions: string[] = []; const groupValues: any[] = []; let paramIndex = 1; for (const col of groupByColumns) { const value = normalizedRows[0][col]; if (value !== undefined && value !== null) { groupConditions.push(`main."${col}" = $${paramIndex}`); groupValues.push(value); paramIndex++; } } if (groupConditions.length > 0) { const groupWhereClause = groupConditions.join(" AND "); console.log( `🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues ); // 그룹핑 기준으로 모든 레코드 조회 const { query: groupQuery } = entityJoinService.buildJoinQuery( tableName, joinConfigs, ["*"], groupWhereClause ); const groupResult = await pool.query(groupQuery, groupValues); const normalizedGroupRows = normalizeDates(groupResult.rows); console.log( `✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개` ); return { success: true, data: normalizedGroupRows, // 🔧 배열로 반환! }; } } return { success: true, data: normalizedRows[0], // 그룹핑 없으면 단일 레코드 }; } } // 기본 쿼리 (Entity Join 없음) const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`; const result = await query(queryText, [id]); if (result.length === 0) { return { success: false, message: "레코드를 찾을 수 없습니다.", error: "RECORD_NOT_FOUND", }; } return { success: true, data: result[0], }; } catch (error) { console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error); return { success: false, message: "레코드 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }; } } /** * 조인된 데이터 조회 (🆕 Entity 조인 지원) */ async getJoinedData( leftTable: string, rightTable: string, leftColumn: string, rightColumn: string, leftValue?: string | number, userCompany?: string, dataFilter?: any, // 🆕 데이터 필터 enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화 displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등) deduplication?: { // 🆕 중복 제거 설정 enabled: boolean; groupByColumn: string; keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; sortColumn?: string; } ): Promise> { try { // 왼쪽 테이블 접근 검증 const leftValidation = await this.validateTableAccess(leftTable); if (!leftValidation.valid) { return leftValidation.error!; } // 오른쪽 테이블 접근 검증 const rightValidation = await this.validateTableAccess(rightTable); if (!rightValidation.valid) { return rightValidation.error!; } // 🆕 Entity 조인이 활성화된 경우 entityJoinService 사용 if (enableEntityJoin) { try { const { entityJoinService } = await import("./entityJoinService"); const joinConfigs = await entityJoinService.detectEntityJoins(rightTable); // 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등) if (displayColumns && Array.isArray(displayColumns)) { // 테이블별로 요청된 컬럼들을 그룹핑 const tableColumns: Record> = {}; for (const col of displayColumns) { if (col.name && col.name.includes(".")) { const [refTable, refColumn] = col.name.split("."); if (!tableColumns[refTable]) { tableColumns[refTable] = new Set(); } tableColumns[refTable].add(refColumn); } } // 각 테이블별로 처리 for (const [refTable, refColumns] of Object.entries(tableColumns)) { // 이미 조인 설정에 있는지 확인 const existingJoins = joinConfigs.filter( (jc) => jc.referenceTable === refTable ); if (existingJoins.length > 0) { // 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리 for (const refColumn of refColumns) { // 이미 해당 컬럼을 표시하는 조인이 있는지 확인 const existingJoin = existingJoins.find( (jc) => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn ); if (!existingJoin) { // 없으면 새 조인 설정 복제하여 추가 const baseJoin = existingJoins[0]; const newJoin = { ...baseJoin, displayColumns: [refColumn], aliasColumn: `${baseJoin.sourceColumn}_${refColumn}`, // 고유한 별칭 생성 (예: item_id_size) // ⚠️ 중요: referenceTable과 referenceColumn을 명시하여 JOIN된 테이블에서 가져옴 referenceTable: refTable, referenceColumn: baseJoin.referenceColumn, // item_number 등 }; joinConfigs.push(newJoin); console.log( `📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})` ); } } } else { console.warn(`⚠️ 조인 설정 없음: ${refTable}`); } } } if (joinConfigs.length > 0) { console.log( `🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정` ); // WHERE 조건 생성 const whereConditions: string[] = []; const values: any[] = []; let paramIndex = 1; // 좌측 테이블 조인 조건 (leftValue로 필터링) // rightColumn을 직접 사용 (customer_item_mapping.customer_id = 'CUST-0002') if (leftValue !== undefined && leftValue !== null) { whereConditions.push(`main."${rightColumn}" = $${paramIndex}`); values.push(leftValue); paramIndex++; } // 회사별 필터링 if (userCompany && userCompany !== "*") { const hasCompanyCode = await this.checkColumnExists( rightTable, "company_code" ); if (hasCompanyCode) { whereConditions.push(`main.company_code = $${paramIndex}`); values.push(userCompany); paramIndex++; } } // 데이터 필터 적용 (buildDataFilterWhereClause 사용) if ( dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0 ) { const { buildDataFilterWhereClause } = await import( "../utils/dataFilterUtil" ); const filterResult = buildDataFilterWhereClause( dataFilter, "main", paramIndex ); if (filterResult.whereClause) { whereConditions.push(filterResult.whereClause); values.push(...filterResult.params); paramIndex += filterResult.params.length; console.log( `🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause ); console.log(`📊 필터 파라미터:`, filterResult.params); } } const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : ""; // Entity 조인 쿼리 빌드 // buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달 const selectColumns = ["*"]; const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery( rightTable, joinConfigs, selectColumns, whereClause, "", undefined, undefined ); console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery); console.log(`🔍 파라미터:`, values); const result = await pool.query(finalQuery, values); // 🔧 날짜 타입 타임존 문제 해결 const normalizeDates = (rows: any[]) => { return rows.map((row) => { const normalized: any = {}; for (const [key, value] of Object.entries(row)) { if (value instanceof Date) { const year = value.getFullYear(); const month = String(value.getMonth() + 1).padStart(2, "0"); const day = String(value.getDate()).padStart(2, "0"); normalized[key] = `${year}-${month}-${day}`; } else { normalized[key] = value; } } return normalized; }); }; const normalizedRows = normalizeDates(result.rows); console.log( `✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)` ); // 🆕 중복 제거 처리 let finalData = normalizedRows; if (deduplication?.enabled && deduplication.groupByColumn) { console.log( `🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}` ); finalData = this.deduplicateData(normalizedRows, deduplication); console.log( `✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개` ); } return { success: true, data: finalData, }; } } catch (error) { console.error("Entity 조인 처리 실패, 기본 조인으로 폴백:", error); // Entity 조인 실패 시 기본 조인으로 폴백 } } // 기본 조인 쿼리 (Entity 조인 미사용 또는 실패 시) let queryText = ` SELECT DISTINCT r.* FROM "${rightTable}" r INNER JOIN "${leftTable}" l ON l."${leftColumn}" = r."${rightColumn}" `; const values: any[] = []; const whereConditions: string[] = []; let paramIndex = 1; // 좌측 값 필터링 if (leftValue !== undefined && leftValue !== null) { whereConditions.push(`l."${leftColumn}" = $${paramIndex}`); values.push(leftValue); paramIndex++; } // 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우) if (userCompany && userCompany !== "*") { const hasCompanyCode = await this.checkColumnExists( rightTable, "company_code" ); if (hasCompanyCode) { whereConditions.push(`r.company_code = $${paramIndex}`); values.push(userCompany); paramIndex++; console.log( `🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}` ); } } // 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용) if ( dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0 ) { const filterResult = buildDataFilterWhereClause( dataFilter, "r", paramIndex ); if (filterResult.whereClause) { whereConditions.push(filterResult.whereClause); values.push(...filterResult.params); paramIndex += filterResult.params.length; console.log( `🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause ); } } // WHERE 절 추가 if (whereConditions.length > 0) { queryText += ` WHERE ${whereConditions.join(" AND ")}`; } console.log("🔍 조인 쿼리 실행:", queryText); console.log("📊 조인 쿼리 파라미터:", values); const result = await query(queryText, values); // 🆕 중복 제거 처리 let finalData = result; if (deduplication?.enabled && deduplication.groupByColumn) { console.log( `🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}` ); finalData = this.deduplicateData(result, deduplication); console.log( `✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개` ); } return { success: true, data: finalData, }; } catch (error) { console.error( `조인 데이터 조회 오류 (${leftTable} → ${rightTable}):`, error ); return { success: false, message: "조인 데이터 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }; } } /** * 레코드 생성 */ async createRecord( tableName: string, data: Record ): Promise> { try { // 테이블 접근 검증 const validation = await this.validateTableAccess(tableName); if (!validation.valid) { return validation.error!; } // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외) const tableColumns = await this.getTableColumnsSimple(tableName); const validColumnNames = new Set( tableColumns.map((col: any) => col.column_name) ); const invalidColumns: string[] = []; const filteredData = Object.fromEntries( Object.entries(data).filter(([key]) => { if (validColumnNames.has(key)) { return true; } invalidColumns.push(key); return false; }) ); if (invalidColumns.length > 0) { console.log( `⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}` ); } const columns = Object.keys(filteredData); const values = Object.values(filteredData); const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); const columnNames = columns.map((col) => `"${col}"`).join(", "); const queryText = ` INSERT INTO "${tableName}" (${columnNames}) VALUES (${placeholders}) RETURNING * `; const result = await query(queryText, values); return { success: true, data: result[0], }; } catch (error) { console.error(`레코드 생성 오류 (${tableName}):`, error); return { success: false, message: "레코드 생성 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }; } } /** * 레코드 수정 */ async updateRecord( tableName: string, id: string | number, data: Record ): Promise> { try { // 테이블 접근 검증 const validation = await this.validateTableAccess(tableName); if (!validation.valid) { return validation.error!; } // _relationInfo 추출 (조인 관계 업데이트용) const relationInfo = data._relationInfo; let cleanData = { ...data }; delete cleanData._relationInfo; // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외) const tableColumns = await this.getTableColumnsSimple(tableName); const validColumnNames = new Set( tableColumns.map((col: any) => col.column_name) ); const invalidColumns: string[] = []; cleanData = Object.fromEntries( Object.entries(cleanData).filter(([key]) => { if (validColumnNames.has(key)) { return true; } invalidColumns.push(key); return false; }) ); if (invalidColumns.length > 0) { console.log( `⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}` ); } // Primary Key 컬럼 찾기 const pkResult = await query<{ attname: string }>( `SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`, [tableName] ); let pkColumn = "id"; if (pkResult.length > 0) { pkColumn = pkResult[0].attname; } const columns = Object.keys(cleanData); const values = Object.values(cleanData); const setClause = columns .map((col, index) => `"${col}" = $${index + 1}`) .join(", "); const queryText = ` UPDATE "${tableName}" SET ${setClause} WHERE "${pkColumn}" = $${values.length + 1} RETURNING * `; values.push(id); const result = await query(queryText, values); if (result.length === 0) { return { success: false, message: "레코드를 찾을 수 없습니다.", error: "RECORD_NOT_FOUND", }; } // 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트 if ( relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn ) { const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo; const newLeftValue = cleanData[leftColumn]; // leftColumn 값이 변경된 경우에만 우측 테이블 업데이트 if (newLeftValue !== undefined && newLeftValue !== oldLeftValue) { console.log("🔗 조인 관계 FK 업데이트:", { rightTable, rightColumn, oldValue: oldLeftValue, newValue: newLeftValue, }); try { const updateRelatedQuery = ` UPDATE "${rightTable}" SET "${rightColumn}" = $1 WHERE "${rightColumn}" = $2 `; const updateResult = await query(updateRelatedQuery, [ newLeftValue, oldLeftValue, ]); console.log( `✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료` ); } catch (relError) { console.error("❌ 연결된 테이블 업데이트 실패:", relError); // 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그 } } } return { success: true, data: result[0], }; } catch (error) { console.error(`레코드 수정 오류 (${tableName}/${id}):`, error); return { success: false, message: "레코드 수정 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }; } } /** * 레코드 삭제 */ async deleteRecord( tableName: string, id: string | number | Record ): Promise> { try { // 테이블 접근 검증 const validation = await this.validateTableAccess(tableName); if (!validation.valid) { return validation.error!; } // Primary Key 컬럼 찾기 (복합키 지원) const pkResult = await query<{ attname: string }>( `SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary ORDER BY a.attnum`, [tableName] ); console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, { pkColumns: pkResult.map((r) => r.attname), pkCount: pkResult.length, inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id, inputIdType: typeof id, }); let whereClauses: string[] = []; let params: any[] = []; if (pkResult.length > 1) { // 복합키인 경우: id가 객체여야 함 console.log( `🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map((r) => r.attname).join(", ")}]` ); if (typeof id === "object" && !Array.isArray(id)) { // id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' } pkResult.forEach((pk, index) => { whereClauses.push(`"${pk.attname}" = $${index + 1}`); params.push(id[pk.attname]); }); } else { // id가 문자열/숫자인 경우: 첫 번째 PK만 사용 (하위 호환성) whereClauses.push(`"${pkResult[0].attname}" = $1`); params.push(id); } } else { // 단일키인 경우 const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id"; whereClauses.push(`"${pkColumn}" = $1`); params.push(typeof id === "object" ? id[pkColumn] : id); } const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`; console.log(`🗑️ 삭제 쿼리:`, queryText, params); const result = await query(queryText, params); // 삭제된 행이 없으면 실패 처리 if (result.length === 0) { console.warn( `⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`, { whereClauses, params } ); return { success: false, message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.", error: "RECORD_NOT_FOUND", }; } console.log( `✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}` ); return { success: true, data: result[0], // 삭제된 레코드 정보 반환 }; } catch (error) { console.error(`레코드 삭제 오류 (${tableName}):`, error); return { success: false, message: "레코드 삭제 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }; } } /** * 조건에 맞는 모든 레코드 삭제 (그룹 삭제) * @param tableName 테이블명 * @param filterConditions 삭제 조건 * @param userCompany 사용자 회사 코드 (멀티테넌시 필터링) */ async deleteGroupRecords( tableName: string, filterConditions: Record, userCompany?: string ): Promise> { try { const validation = await this.validateTableAccess(tableName); if (!validation.valid) { return validation.error!; } const whereConditions: string[] = []; const whereValues: any[] = []; let paramIndex = 1; // 사용자 필터 조건 추가 for (const [key, value] of Object.entries(filterConditions)) { whereConditions.push(`"${key}" = $${paramIndex}`); whereValues.push(value); paramIndex++; } if (whereConditions.length === 0) { return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS", }; } // 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외) const hasCompanyCode = await this.checkColumnExists(tableName, "company_code"); if (hasCompanyCode && userCompany && userCompany !== "*") { whereConditions.push(`"company_code" = $${paramIndex}`); whereValues.push(userCompany); paramIndex++; console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`); } const whereClause = whereConditions.join(" AND "); const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions, userCompany, whereClause, }); const result = await pool.query(deleteQuery, whereValues); console.log(`✅ 그룹 삭제 성공: ${result.rowCount}개`); return { success: true, data: { deleted: result.rowCount || 0 } }; } catch (error) { console.error("그룹 삭제 오류:", error); return { success: false, message: "그룹 삭제 실패", error: error instanceof Error ? error.message : "Unknown error", }; } } /** * 그룹화된 데이터 UPSERT * - 부모 키(예: customer_id, item_id)와 레코드 배열을 받아 * - 기존 DB의 레코드들과 비교하여 INSERT/UPDATE/DELETE 수행 * - 각 레코드의 모든 필드 조합을 고유 키로 사용 */ async upsertGroupedRecords( tableName: string, parentKeys: Record, records: Array>, userCompany?: string, userId?: string ): Promise< ServiceResponse<{ inserted: number; updated: number; deleted: number }> > { try { // 테이블 접근 권한 검증 const validation = await this.validateTableAccess(tableName); if (!validation.valid) { return validation.error!; } // Primary Key 감지 const pkColumns = await this.getPrimaryKeyColumns(tableName); if (!pkColumns || pkColumns.length === 0) { return { success: false, message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`, error: "PRIMARY_KEY_NOT_FOUND", }; } const pkColumn = pkColumns[0]; // 첫 번째 PK 사용 console.log(`🔍 UPSERT 시작: ${tableName}`, { parentKeys, newRecordsCount: records.length, primaryKey: pkColumn, }); // 1. 기존 DB 레코드 조회 (parentKeys 기준) const whereConditions: string[] = []; const whereValues: any[] = []; let paramIndex = 1; for (const [key, value] of Object.entries(parentKeys)) { whereConditions.push(`"${key}" = $${paramIndex}`); whereValues.push(value); paramIndex++; } const whereClause = whereConditions.join(" AND "); const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`; console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues, }); const existingRecords = await pool.query(selectQuery, whereValues); console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`); // 2. 새 레코드와 기존 레코드 비교 let inserted = 0; let updated = 0; let deleted = 0; // 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수 const normalizeDateValue = (value: any): any => { if (value == null) return value; // ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ) if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) { return value.split("T")[0]; // YYYY-MM-DD 만 추출 } return value; }; // 새 레코드 처리 (INSERT or UPDATE) for (const newRecord of records) { console.log(`🔍 처리할 새 레코드:`, newRecord); // 날짜 필드 정규화 const normalizedRecord: Record = {}; for (const [key, value] of Object.entries(newRecord)) { normalizedRecord[key] = normalizeDateValue(value); } console.log(`🔄 정규화된 레코드:`, normalizedRecord); // 전체 레코드 데이터 (parentKeys + normalizedRecord) const fullRecord = { ...parentKeys, ...normalizedRecord }; // 고유 키: parentKeys 제외한 나머지 필드들 const uniqueFields = Object.keys(normalizedRecord); console.log(`🔑 고유 필드들:`, uniqueFields); // 기존 레코드에서 일치하는 것 찾기 const existingRecord = existingRecords.rows.find((existing) => { return uniqueFields.every((field) => { const existingValue = existing[field]; const newValue = normalizedRecord[field]; // null/undefined 처리 if (existingValue == null && newValue == null) return true; if (existingValue == null || newValue == null) return false; // Date 타입 처리 if (existingValue instanceof Date && typeof newValue === "string") { return ( existingValue.toISOString().split("T")[0] === newValue.split("T")[0] ); } // 문자열 비교 return String(existingValue) === String(newValue); }); }); if (existingRecord) { // UPDATE: 기존 레코드가 있으면 업데이트 const updateFields: string[] = []; const updateValues: any[] = []; let updateParamIndex = 1; for (const [key, value] of Object.entries(fullRecord)) { if (key !== pkColumn) { // Primary Key는 업데이트하지 않음 updateFields.push(`"${key}" = $${updateParamIndex}`); updateValues.push(value); updateParamIndex++; } } updateValues.push(existingRecord[pkColumn]); // WHERE 조건용 const updateQuery = ` UPDATE "${tableName}" SET ${updateFields.join(", ")}, updated_date = NOW() WHERE "${pkColumn}" = $${updateParamIndex} `; await pool.query(updateQuery, updateValues); updated++; console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); } else { // INSERT: 기존 레코드가 없으면 삽입 // 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id) // created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정 const { created_date: _, ...recordWithoutCreatedDate } = fullRecord; const recordWithMeta: Record = { ...recordWithoutCreatedDate, id: uuidv4(), // 새 ID 생성 created_date: "NOW()", updated_date: "NOW()", }; // company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만) if ( !recordWithMeta.company_code && userCompany && userCompany !== "*" ) { recordWithMeta.company_code = userCompany; } // writer가 없으면 userId 사용 if (!recordWithMeta.writer && userId) { recordWithMeta.writer = userId; } const insertFields = Object.keys(recordWithMeta).filter( (key) => recordWithMeta[key] !== "NOW()" ); const insertPlaceholders: string[] = []; const insertValues: any[] = []; let insertParamIndex = 1; for (const field of Object.keys(recordWithMeta)) { if (recordWithMeta[field] === "NOW()") { insertPlaceholders.push("NOW()"); } else { insertPlaceholders.push(`$${insertParamIndex}`); insertValues.push(recordWithMeta[field]); insertParamIndex++; } } const insertQuery = ` INSERT INTO "${tableName}" (${Object.keys(recordWithMeta) .map((f) => `"${f}"`) .join(", ")}) VALUES (${insertPlaceholders.join(", ")}) `; console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues, }); await pool.query(insertQuery, insertValues); inserted++; console.log(`➕ INSERT: 새 레코드`); } } // 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것) for (const existingRecord of existingRecords.rows) { const uniqueFields = Object.keys(records[0] || {}); const stillExists = records.some((newRecord) => { return uniqueFields.every((field) => { const existingValue = existingRecord[field]; const newValue = newRecord[field]; if (existingValue == null && newValue == null) return true; if (existingValue == null || newValue == null) return false; if (existingValue instanceof Date && typeof newValue === "string") { return ( existingValue.toISOString().split("T")[0] === newValue.split("T")[0] ); } return String(existingValue) === String(newValue); }); }); if (!stillExists) { // DELETE: 새 레코드에 없으면 삭제 const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; await pool.query(deleteQuery, [existingRecord[pkColumn]]); deleted++; console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`); } } console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted }); return { success: true, data: { inserted, updated, deleted }, }; } catch (error) { console.error(`UPSERT 오류 (${tableName}):`, error); return { success: false, message: "데이터 저장 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }; } } } export const dataService = new DataService();