613 lines
17 KiB
TypeScript
613 lines
17 KiB
TypeScript
/**
|
|
* 동적 데이터 서비스
|
|
*
|
|
* 주요 특징:
|
|
* 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능
|
|
* 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지
|
|
* 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용
|
|
* 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증
|
|
*
|
|
* 보안:
|
|
* - 테이블명은 영문, 숫자, 언더스코어만 허용
|
|
* - 시스템 테이블(pg_*, information_schema 등) 접근 금지
|
|
* - company_code 컬럼이 있는 테이블은 자동으로 회사별 격리
|
|
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
|
|
*/
|
|
import { query, queryOne } from "../database/db";
|
|
|
|
interface GetTableDataParams {
|
|
tableName: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
orderBy?: string;
|
|
filters?: Record<string, string>;
|
|
userCompany?: string;
|
|
}
|
|
|
|
interface ServiceResponse<T> {
|
|
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 async validateTableAccess(
|
|
tableName: string
|
|
): Promise<{ valid: boolean; error?: ServiceResponse<any> }> {
|
|
// 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<ServiceResponse<any[]>> {
|
|
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<any>(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<ServiceResponse<any[]>> {
|
|
try {
|
|
// 테이블 접근 검증
|
|
const validation = await this.validateTableAccess(tableName);
|
|
if (!validation.valid) {
|
|
return validation.error!;
|
|
}
|
|
|
|
const columns = await this.getTableColumnsSimple(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,
|
|
};
|
|
})
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
data: columnsWithLabels,
|
|
};
|
|
} catch (error) {
|
|
console.error(`컬럼 정보 조회 오류 (${tableName}):`, error);
|
|
return {
|
|
success: false,
|
|
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블 존재 여부 확인
|
|
*/
|
|
private async checkTableExists(tableName: string): Promise<boolean> {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 컬럼 존재 여부 확인
|
|
*/
|
|
private async checkColumnExists(
|
|
tableName: string,
|
|
columnName: string
|
|
): Promise<boolean> {
|
|
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<any[]> {
|
|
const result = await query<any>(
|
|
`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<string | null> {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 레코드 상세 조회
|
|
*/
|
|
async getRecordDetail(
|
|
tableName: string,
|
|
id: string | number
|
|
): Promise<ServiceResponse<any>> {
|
|
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;
|
|
}
|
|
|
|
const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
|
const result = await query<any>(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",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 조인된 데이터 조회
|
|
*/
|
|
async getJoinedData(
|
|
leftTable: string,
|
|
rightTable: string,
|
|
leftColumn: string,
|
|
rightColumn: string,
|
|
leftValue?: string | number
|
|
): Promise<ServiceResponse<any[]>> {
|
|
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!;
|
|
}
|
|
|
|
let queryText = `
|
|
SELECT r.*
|
|
FROM "${rightTable}" r
|
|
INNER JOIN "${leftTable}" l
|
|
ON l."${leftColumn}" = r."${rightColumn}"
|
|
`;
|
|
|
|
const values: any[] = [];
|
|
if (leftValue !== undefined && leftValue !== null) {
|
|
queryText += ` WHERE l."${leftColumn}" = $1`;
|
|
values.push(leftValue);
|
|
}
|
|
|
|
const result = await query<any>(queryText, values);
|
|
|
|
return {
|
|
success: true,
|
|
data: result,
|
|
};
|
|
} 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<string, any>
|
|
): Promise<ServiceResponse<any>> {
|
|
try {
|
|
// 테이블 접근 검증
|
|
const validation = await this.validateTableAccess(tableName);
|
|
if (!validation.valid) {
|
|
return validation.error!;
|
|
}
|
|
|
|
const columns = Object.keys(data);
|
|
const values = Object.values(data);
|
|
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<any>(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<string, any>
|
|
): Promise<ServiceResponse<any>> {
|
|
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;
|
|
}
|
|
|
|
const columns = Object.keys(data);
|
|
const values = Object.values(data);
|
|
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<any>(queryText, values);
|
|
|
|
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",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 레코드 삭제
|
|
*/
|
|
async deleteRecord(
|
|
tableName: string,
|
|
id: string | number
|
|
): Promise<ServiceResponse<void>> {
|
|
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;
|
|
}
|
|
|
|
const queryText = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
|
await query<any>(queryText, [id]);
|
|
|
|
return {
|
|
success: true,
|
|
};
|
|
} catch (error) {
|
|
console.error(`레코드 삭제 오류 (${tableName}/${id}):`, error);
|
|
return {
|
|
success: false,
|
|
message: "레코드 삭제 중 오류가 발생했습니다.",
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
export const dataService = new DataService();
|