타입 관리 개선 및 화면 비율조정 중간커밋

This commit is contained in:
kjs
2025-09-19 18:43:55 +09:00
parent baa656dee5
commit 4b28530fec
47 changed files with 13149 additions and 1230 deletions

View File

@@ -1,8 +1,9 @@
import { Response } from "express";
import { dynamicFormService } from "../services/dynamicFormService";
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
import { AuthenticatedRequest } from "../types/auth";
// 폼 데이터 저장
// 폼 데이터 저장 (기존 버전 - 레거시 지원)
export const saveFormData = async (
req: AuthenticatedRequest,
res: Response
@@ -55,6 +56,55 @@ export const saveFormData = async (
}
};
// 개선된 폼 데이터 저장 (새 버전)
export const saveFormDataEnhanced = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { screenId, tableName, data } = req.body;
// 필수 필드 검증
if (screenId === undefined || screenId === null || !tableName || !data) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (screenId, tableName, data)",
});
}
// 메타데이터 추가
const formDataWithMeta = {
...data,
created_by: userId,
updated_by: userId,
screen_id: screenId,
};
// company_code 처리
if (data.company_code !== undefined) {
formDataWithMeta.company_code = data.company_code;
} else if (companyCode && companyCode !== "*") {
formDataWithMeta.company_code = companyCode;
}
// 개선된 서비스 사용
const result = await enhancedDynamicFormService.saveFormData(
screenId,
tableName,
formDataWithMeta
);
res.json(result);
} catch (error: any) {
console.error("❌ 개선된 폼 데이터 저장 실패:", error);
res.status(500).json({
success: false,
message: error.message || "데이터 저장에 실패했습니다.",
});
}
};
// 폼 데이터 업데이트
export const updateFormData = async (
req: AuthenticatedRequest,

View File

@@ -735,6 +735,207 @@ export async function editTableData(
}
}
/**
* 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용)
*/
export async function getTableSchema(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 테이블 스키마 정보 조회 시작: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const schema = await tableManagementService.getTableSchema(tableName);
logger.info(
`테이블 스키마 정보 조회 완료: ${tableName}, ${schema.length}개 컬럼`
);
const response: ApiResponse<ColumnTypeInfo[]> = {
success: true,
message: "테이블 스키마 정보를 성공적으로 조회했습니다.",
data: schema,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 스키마 정보 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 스키마 정보 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_SCHEMA_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 테이블 존재 여부 확인
*/
export async function checkTableExists(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 테이블 존재 여부 확인 시작: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const exists = await tableManagementService.checkTableExists(tableName);
logger.info(`테이블 존재 여부 확인 완료: ${tableName} = ${exists}`);
const response: ApiResponse<{ exists: boolean }> = {
success: true,
message: "테이블 존재 여부를 확인했습니다.",
data: { exists },
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 존재 여부 확인 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 존재 여부 확인 중 오류가 발생했습니다.",
error: {
code: "TABLE_EXISTS_CHECK_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 컬럼 웹타입 정보 조회 (화면관리 연동용)
*/
export async function getColumnWebTypes(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 컬럼 웹타입 정보 조회 시작: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const webTypes = await tableManagementService.getColumnWebTypes(tableName);
logger.info(
`컬럼 웹타입 정보 조회 완료: ${tableName}, ${webTypes.length}개 컬럼`
);
const response: ApiResponse<ColumnTypeInfo[]> = {
success: true,
message: "컬럼 웹타입 정보를 성공적으로 조회했습니다.",
data: webTypes,
};
res.status(200).json(response);
} catch (error) {
logger.error("컬럼 웹타입 정보 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 웹타입 정보 조회 중 오류가 발생했습니다.",
error: {
code: "COLUMN_WEB_TYPES_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 데이터베이스 연결 상태 확인
*/
export async function checkDatabaseConnection(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.info("=== 데이터베이스 연결 상태 확인 시작 ===");
const tableManagementService = new TableManagementService();
const connectionStatus =
await tableManagementService.checkDatabaseConnection();
logger.info(
`데이터베이스 연결 상태: ${connectionStatus.connected ? "연결됨" : "연결 안됨"}`
);
const response: ApiResponse<{ connected: boolean; message: string }> = {
success: true,
message: "데이터베이스 연결 상태를 확인했습니다.",
data: connectionStatus,
};
res.status(200).json(response);
} catch (error) {
logger.error("데이터베이스 연결 상태 확인 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "데이터베이스 연결 상태 확인 중 오류가 발생했습니다.",
error: {
code: "DATABASE_CONNECTION_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 테이블 데이터 삭제
*/

View File

@@ -2,6 +2,7 @@ import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
saveFormData,
saveFormDataEnhanced,
updateFormData,
updateFormDataPartial,
deleteFormData,
@@ -18,7 +19,8 @@ const router = express.Router();
router.use(authenticateToken);
// 폼 데이터 CRUD
router.post("/save", saveFormData);
router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
router.put("/:id", updateFormData);
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
router.delete("/:id", deleteFormData);

View File

@@ -13,6 +13,10 @@ import {
addTableData,
editTableData,
deleteTableData,
getTableSchema,
checkTableExists,
getColumnWebTypes,
checkDatabaseConnection,
} from "../controllers/tableManagementController";
const router = express.Router();
@@ -74,6 +78,42 @@ router.put(
updateColumnWebType
);
/**
* 개별 컬럼 설정 업데이트 (PUT 방식)
* PUT /api/table-management/tables/:tableName/columns/:columnName
*/
router.put("/tables/:tableName/columns/:columnName", updateColumnSettings);
/**
* 여러 컬럼 설정 일괄 업데이트
* PUT /api/table-management/tables/:tableName/columns/batch
*/
router.put("/tables/:tableName/columns/batch", updateAllColumnSettings);
/**
* 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용)
* GET /api/table-management/tables/:tableName/schema
*/
router.get("/tables/:tableName/schema", getTableSchema);
/**
* 테이블 존재 여부 확인
* GET /api/table-management/tables/:tableName/exists
*/
router.get("/tables/:tableName/exists", checkTableExists);
/**
* 컬럼 웹타입 정보 조회 (화면관리 연동용)
* GET /api/table-management/tables/:tableName/web-types
*/
router.get("/tables/:tableName/web-types", getColumnWebTypes);
/**
* 데이터베이스 연결 상태 확인
* GET /api/table-management/health
*/
router.get("/health", checkDatabaseConnection);
/**
* 테이블 데이터 조회 (페이징 + 검색)
* POST /api/table-management/tables/:tableName/data

View File

@@ -0,0 +1,786 @@
/**
* 개선된 동적 폼 서비스
* 타입 안전성과 검증 강화
*/
import { PrismaClient } from "@prisma/client";
import {
WebType,
DynamicWebType,
normalizeWebType,
isValidWebType,
WEB_TYPE_TO_POSTGRES_CONVERTER,
WEB_TYPE_VALIDATION_PATTERNS,
} from "../types/unified-web-types";
import { DataflowControlService } from "./dataflowControlService";
const prisma = new PrismaClient();
// 테이블 컬럼 정보
export interface TableColumn {
column_name: string;
data_type: string;
is_nullable: string;
column_default: any;
character_maximum_length?: number;
numeric_precision?: number;
numeric_scale?: number;
}
// 컬럼 웹타입 정보
export interface ColumnWebTypeInfo {
columnName: string;
webType: WebType;
isRequired: boolean;
validationRules?: Record<string, any>;
defaultValue?: any;
}
// 폼 데이터 검증 결과
export interface FormValidationResult {
isValid: boolean;
errors: FormValidationError[];
warnings: FormValidationWarning[];
transformedData: Record<string, any>;
}
export interface FormValidationError {
field: string;
code: string;
message: string;
value?: any;
}
export interface FormValidationWarning {
field: string;
code: string;
message: string;
suggestion?: string;
}
// 저장 결과
export interface FormDataResult {
success: boolean;
message: string;
data?: any;
affectedRows?: number;
insertedId?: any;
validationResult?: FormValidationResult;
}
export class EnhancedDynamicFormService {
private dataflowControlService = new DataflowControlService();
private columnCache = new Map<string, TableColumn[]>();
private webTypeCache = new Map<string, ColumnWebTypeInfo[]>();
/**
* 폼 데이터 저장 (메인 메서드)
*/
async saveFormData(
screenId: number,
tableName: string,
data: Record<string, any>
): Promise<FormDataResult> {
const startTime = Date.now();
try {
console.log(`🚀 개선된 폼 저장 시작: ${tableName}`, {
screenId,
dataKeys: Object.keys(data),
timestamp: new Date().toISOString(),
});
// 1. 테이블 존재 여부 확인
const tableExists = await this.validateTableExists(tableName);
if (!tableExists) {
return {
success: false,
message: `테이블 '${tableName}'이 존재하지 않습니다.`,
};
}
// 2. 스키마 정보 로드
const [tableColumns, columnWebTypes] = await Promise.all([
this.getTableColumns(tableName),
this.getColumnWebTypes(tableName),
]);
// 3. 폼 데이터 검증
const validationResult = await this.validateFormData(
data,
tableColumns,
columnWebTypes,
tableName
);
if (!validationResult.isValid) {
console.error("❌ 폼 데이터 검증 실패:", validationResult.errors);
return {
success: false,
message: this.formatValidationErrors(validationResult.errors),
validationResult,
};
}
// 4. 데이터 저장 수행
const saveResult = await this.performDataSave(
tableName,
validationResult.transformedData,
tableColumns
);
const duration = Date.now() - startTime;
console.log(`✅ 폼 저장 완료: ${duration}ms`);
return {
success: true,
message: "데이터가 성공적으로 저장되었습니다.",
data: saveResult.data,
affectedRows: saveResult.affectedRows,
insertedId: saveResult.insertedId,
validationResult,
};
} catch (error: any) {
console.error("❌ 폼 저장 중 오류:", error);
return {
success: false,
message: this.formatErrorMessage(error),
data: { error: error.message, stack: error.stack },
};
}
}
/**
* 테이블 존재 여부 확인
*/
private async validateTableExists(tableName: string): Promise<boolean> {
try {
const result = await prisma.$queryRawUnsafe(
`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = $1
) as exists
`,
tableName
);
return (result as any)[0]?.exists || false;
} catch (error) {
console.error(`❌ 테이블 존재 여부 확인 실패: ${tableName}`, error);
return false;
}
}
/**
* 테이블 컬럼 정보 조회 (캐시 포함)
*/
private async getTableColumns(tableName: string): Promise<TableColumn[]> {
// 캐시 확인
const cached = this.columnCache.get(tableName);
if (cached) {
return cached;
}
try {
const columns = (await prisma.$queryRawUnsafe(
`
SELECT
column_name,
data_type,
is_nullable,
column_default,
character_maximum_length,
numeric_precision,
numeric_scale
FROM information_schema.columns
WHERE table_name = $1
ORDER BY ordinal_position
`,
tableName
)) as TableColumn[];
// 캐시 저장 (10분)
this.columnCache.set(tableName, columns);
setTimeout(() => this.columnCache.delete(tableName), 10 * 60 * 1000);
return columns;
} catch (error) {
console.error(`❌ 테이블 컬럼 정보 조회 실패: ${tableName}`, error);
throw new Error(`테이블 컬럼 정보를 조회할 수 없습니다: ${error}`);
}
}
/**
* 컬럼 웹타입 정보 조회
*/
private async getColumnWebTypes(
tableName: string
): Promise<ColumnWebTypeInfo[]> {
// 캐시 확인
const cached = this.webTypeCache.get(tableName);
if (cached) {
return cached;
}
try {
// table_type_columns에서 웹타입 정보 조회
const webTypeData = (await prisma.$queryRawUnsafe(
`
SELECT
column_name,
web_type,
is_nullable,
detail_settings
FROM table_type_columns
WHERE table_name = $1
`,
tableName
)) as any[];
const columnWebTypes: ColumnWebTypeInfo[] = webTypeData.map((row) => ({
columnName: row.column_name,
webType: normalizeWebType(row.web_type || "text"),
isRequired: row.is_nullable === "N",
validationRules: this.parseDetailSettings(row.detail_settings),
defaultValue: null,
}));
// 캐시 저장 (10분)
this.webTypeCache.set(tableName, columnWebTypes);
setTimeout(() => this.webTypeCache.delete(tableName), 10 * 60 * 1000);
return columnWebTypes;
} catch (error) {
console.error(`❌ 컬럼 웹타입 정보 조회 실패: ${tableName}`, error);
// 실패 시 빈 배열 반환 (기본 검증만 수행)
return [];
}
}
/**
* 상세 설정 파싱
*/
private parseDetailSettings(
detailSettings: string | null
): Record<string, any> {
if (!detailSettings) return {};
try {
return JSON.parse(detailSettings);
} catch {
return {};
}
}
/**
* 폼 데이터 검증
*/
private async validateFormData(
data: Record<string, any>,
tableColumns: TableColumn[],
columnWebTypes: ColumnWebTypeInfo[],
tableName: string
): Promise<FormValidationResult> {
const errors: FormValidationError[] = [];
const warnings: FormValidationWarning[] = [];
const transformedData: Record<string, any> = {};
const columnMap = new Map(
tableColumns.map((col) => [col.column_name, col])
);
const webTypeMap = new Map(columnWebTypes.map((wt) => [wt.columnName, wt]));
console.log(`📋 폼 데이터 검증 시작: ${tableName}`, {
inputFields: Object.keys(data).length,
tableColumns: tableColumns.length,
webTypeColumns: columnWebTypes.length,
});
// 입력된 각 필드 검증
for (const [fieldName, value] of Object.entries(data)) {
const column = columnMap.get(fieldName);
const webTypeInfo = webTypeMap.get(fieldName);
// 1. 컬럼 존재 여부 확인
if (!column) {
errors.push({
field: fieldName,
code: "COLUMN_NOT_EXISTS",
message: `테이블 '${tableName}'에 '${fieldName}' 컬럼이 존재하지 않습니다.`,
value,
});
continue;
}
// 2. 필수값 검증
if (webTypeInfo?.isRequired && this.isEmptyValue(value)) {
errors.push({
field: fieldName,
code: "REQUIRED_FIELD",
message: `'${fieldName}'은(는) 필수 입력 항목입니다.`,
value,
});
continue;
}
// 3. 웹타입별 검증 및 변환
if (webTypeInfo?.webType) {
const validationResult = this.validateFieldByWebType(
fieldName,
value,
webTypeInfo.webType,
webTypeInfo.validationRules
);
if (!validationResult.isValid) {
errors.push(validationResult.error!);
continue;
}
transformedData[fieldName] = validationResult.transformedValue;
} else {
// 웹타입 정보가 없는 경우 DB 타입 기반 변환
transformedData[fieldName] = this.convertValueForPostgreSQL(
value,
column.data_type
);
}
// 4. DB 제약조건 검증
const constraintValidation = this.validateDatabaseConstraints(
fieldName,
transformedData[fieldName],
column
);
if (!constraintValidation.isValid) {
errors.push(constraintValidation.error!);
continue;
}
}
// 필수 컬럼 누락 확인
const requiredColumns = columnWebTypes.filter((wt) => wt.isRequired);
for (const requiredCol of requiredColumns) {
if (
!(requiredCol.columnName in data) ||
this.isEmptyValue(data[requiredCol.columnName])
) {
if (!errors.some((e) => e.field === requiredCol.columnName)) {
errors.push({
field: requiredCol.columnName,
code: "MISSING_REQUIRED_FIELD",
message: `필수 입력 항목 '${requiredCol.columnName}'이 누락되었습니다.`,
});
}
}
}
console.log(`📋 폼 데이터 검증 완료:`, {
errors: errors.length,
warnings: warnings.length,
transformedFields: Object.keys(transformedData).length,
});
return {
isValid: errors.length === 0,
errors,
warnings,
transformedData,
};
}
/**
* 웹타입별 필드 검증
*/
private validateFieldByWebType(
fieldName: string,
value: any,
webType: WebType,
validationRules?: Record<string, any>
): { isValid: boolean; error?: FormValidationError; transformedValue?: any } {
// 빈 값 처리
if (this.isEmptyValue(value)) {
return { isValid: true, transformedValue: null };
}
// 웹타입 유효성 확인
if (!isValidWebType(webType)) {
return {
isValid: false,
error: {
field: fieldName,
code: "INVALID_WEB_TYPE",
message: `'${fieldName}'의 웹타입 '${webType}'이 올바르지 않습니다.`,
value,
},
};
}
// 패턴 검증
const pattern = WEB_TYPE_VALIDATION_PATTERNS[webType];
if (pattern && !pattern.test(String(value))) {
return {
isValid: false,
error: {
field: fieldName,
code: "INVALID_FORMAT",
message: `'${fieldName}'의 형식이 올바르지 않습니다.`,
value,
},
};
}
// 값 변환
try {
const converter = WEB_TYPE_TO_POSTGRES_CONVERTER[webType];
const transformedValue = converter ? converter(value) : value;
return { isValid: true, transformedValue };
} catch (error) {
return {
isValid: false,
error: {
field: fieldName,
code: "CONVERSION_ERROR",
message: `'${fieldName}' 값 변환 중 오류가 발생했습니다: ${error}`,
value,
},
};
}
}
/**
* 데이터베이스 제약조건 검증
*/
private validateDatabaseConstraints(
fieldName: string,
value: any,
column: TableColumn
): { isValid: boolean; error?: FormValidationError } {
// NULL 제약조건
if (
column.is_nullable === "NO" &&
(value === null || value === undefined)
) {
return {
isValid: false,
error: {
field: fieldName,
code: "NOT_NULL_VIOLATION",
message: `'${fieldName}'에는 NULL 값을 입력할 수 없습니다.`,
value,
},
};
}
// 문자열 길이 제약조건
if (column.character_maximum_length && typeof value === "string") {
if (value.length > column.character_maximum_length) {
return {
isValid: false,
error: {
field: fieldName,
code: "STRING_TOO_LONG",
message: `'${fieldName}'의 길이는 최대 ${column.character_maximum_length}자까지 입력할 수 있습니다.`,
value,
},
};
}
}
// 숫자 정밀도 검증
if (column.numeric_precision && typeof value === "number") {
const totalDigits = Math.abs(value).toString().replace(".", "").length;
if (totalDigits > column.numeric_precision) {
return {
isValid: false,
error: {
field: fieldName,
code: "NUMERIC_OVERFLOW",
message: `'${fieldName}'의 숫자 자릿수가 허용 범위를 초과했습니다.`,
value,
},
};
}
}
return { isValid: true };
}
/**
* 빈 값 확인
*/
private isEmptyValue(value: any): boolean {
return (
value === null ||
value === undefined ||
value === "" ||
(Array.isArray(value) && value.length === 0)
);
}
/**
* 데이터 저장 수행
*/
private async performDataSave(
tableName: string,
data: Record<string, any>,
tableColumns: TableColumn[]
): Promise<{ data?: any; affectedRows: number; insertedId?: any }> {
try {
// Primary Key 확인
const primaryKeys = await this.getPrimaryKeys(tableName);
const hasExistingRecord =
primaryKeys.length > 0 &&
primaryKeys.every((pk) => data[pk] !== undefined && data[pk] !== null);
if (hasExistingRecord) {
// UPDATE 수행
return await this.performUpdate(tableName, data, primaryKeys);
} else {
// INSERT 수행
return await this.performInsert(tableName, data);
}
} catch (error) {
console.error(`❌ 데이터 저장 실패: ${tableName}`, error);
throw error;
}
}
/**
* Primary Key 조회
*/
private async getPrimaryKeys(tableName: string): Promise<string[]> {
try {
const result = (await prisma.$queryRawUnsafe(
`
SELECT column_name
FROM information_schema.key_column_usage
WHERE table_name = $1
AND constraint_name LIKE '%_pkey'
`,
tableName
)) as any[];
return result.map((row) => row.column_name);
} catch (error) {
console.error(`❌ Primary Key 조회 실패: ${tableName}`, error);
return [];
}
}
/**
* INSERT 수행
*/
private async performInsert(
tableName: string,
data: Record<string, any>
): Promise<{ data?: any; affectedRows: number; insertedId?: any }> {
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
const insertQuery = `
INSERT INTO ${tableName} (${columns.join(", ")})
VALUES (${placeholders})
RETURNING *
`;
console.log(`📝 INSERT 쿼리 실행: ${tableName}`, {
columns: columns.length,
query: insertQuery.replace(/\n\s+/g, " "),
});
const result = (await prisma.$queryRawUnsafe(
insertQuery,
...values
)) as any[];
return {
data: result[0],
affectedRows: result.length,
insertedId: result[0]?.id || result[0],
};
}
/**
* UPDATE 수행
*/
private async performUpdate(
tableName: string,
data: Record<string, any>,
primaryKeys: string[]
): Promise<{ data?: any; affectedRows: number; insertedId?: any }> {
const updateColumns = Object.keys(data).filter(
(col) => !primaryKeys.includes(col)
);
const whereColumns = primaryKeys.filter((pk) => data[pk] !== undefined);
if (updateColumns.length === 0) {
throw new Error("업데이트할 컬럼이 없습니다.");
}
const setClause = updateColumns
.map((col, index) => `${col} = $${index + 1}`)
.join(", ");
const whereClause = whereColumns
.map((col, index) => `${col} = $${updateColumns.length + index + 1}`)
.join(" AND ");
const updateValues = [
...updateColumns.map((col) => data[col]),
...whereColumns.map((col) => data[col]),
];
const updateQuery = `
UPDATE ${tableName}
SET ${setClause}
WHERE ${whereClause}
RETURNING *
`;
console.log(`📝 UPDATE 쿼리 실행: ${tableName}`, {
updateColumns: updateColumns.length,
whereColumns: whereColumns.length,
query: updateQuery.replace(/\n\s+/g, " "),
});
const result = (await prisma.$queryRawUnsafe(
updateQuery,
...updateValues
)) as any[];
return {
data: result[0],
affectedRows: result.length,
};
}
/**
* PostgreSQL 타입 변환 (레거시 지원)
*/
private convertValueForPostgreSQL(value: any, dataType: string): any {
if (value === null || value === undefined || value === "") {
return null;
}
const lowerDataType = dataType.toLowerCase();
// 숫자 타입 처리
if (
lowerDataType.includes("integer") ||
lowerDataType.includes("bigint") ||
lowerDataType.includes("serial")
) {
return parseInt(value) || null;
}
if (
lowerDataType.includes("numeric") ||
lowerDataType.includes("decimal") ||
lowerDataType.includes("real") ||
lowerDataType.includes("double")
) {
return parseFloat(value) || null;
}
// 불린 타입 처리
if (lowerDataType.includes("boolean")) {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
return value.toLowerCase() === "true" || value === "1";
}
return Boolean(value);
}
// 날짜/시간 타입 처리
if (
lowerDataType.includes("timestamp") ||
lowerDataType.includes("datetime")
) {
const date = new Date(value);
return isNaN(date.getTime()) ? null : date.toISOString();
}
if (lowerDataType.includes("date")) {
const date = new Date(value);
return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0];
}
// JSON 타입 처리
if (lowerDataType.includes("json")) {
if (typeof value === "string") {
try {
JSON.parse(value);
return value;
} catch {
return JSON.stringify(value);
}
}
return JSON.stringify(value);
}
return String(value);
}
/**
* 오류 메시지 포맷팅
*/
private formatValidationErrors(errors: FormValidationError[]): string {
if (errors.length === 0) return "알 수 없는 오류가 발생했습니다.";
if (errors.length === 1) return errors[0].message;
return `다음 오류들을 수정해주세요:\n• ${errors.map((e) => e.message).join("\n• ")}`;
}
/**
* 시스템 오류 메시지 포맷팅
*/
private formatErrorMessage(error: any): string {
if (error.code === "23505") {
return "중복된 데이터가 이미 존재합니다.";
}
if (error.code === "23503") {
return "참조 무결성 제약조건을 위반했습니다.";
}
if (error.code === "23502") {
return "필수 입력 항목이 누락되었습니다.";
}
if (
error.message?.includes("relation") &&
error.message?.includes("does not exist")
) {
return "지정된 테이블이 존재하지 않습니다.";
}
return `저장 중 오류가 발생했습니다: ${error.message || error}`;
}
/**
* 캐시 클리어
*/
public clearCache(): void {
this.columnCache.clear();
this.webTypeCache.clear();
console.log("🧹 동적 폼 서비스 캐시가 클리어되었습니다.");
}
/**
* 테이블별 캐시 클리어
*/
public clearTableCache(tableName: string): void {
this.columnCache.delete(tableName);
this.webTypeCache.delete(tableName);
console.log(`🧹 테이블 '${tableName}' 캐시가 클리어되었습니다.`);
}
}
// 싱글톤 인스턴스
export const enhancedDynamicFormService = new EnhancedDynamicFormService();

View File

@@ -1013,23 +1013,52 @@ export class ScreenManagementService {
* 데이터 타입으로부터 웹타입 추론
*/
private inferWebType(dataType: string): WebType {
// 통합 타입 매핑에서 import
const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types");
const lowerType = dataType.toLowerCase();
if (lowerType.includes("char") || lowerType.includes("text")) {
return "text";
} else if (
lowerType.includes("int") ||
lowerType.includes("numeric") ||
lowerType.includes("decimal")
) {
// 정확한 매핑 우선 확인
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
return DB_TYPE_TO_WEB_TYPE[lowerType];
}
// 부분 문자열 매칭 (더 정교한 규칙)
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
if (
lowerType.includes(dbType.toLowerCase()) ||
dbType.toLowerCase().includes(lowerType)
) {
return webType as WebType;
}
}
// 추가 정밀 매핑
if (lowerType.includes("int") && !lowerType.includes("point")) {
return "number";
} else if (lowerType.includes("date") || lowerType.includes("time")) {
} else if (lowerType.includes("numeric") || lowerType.includes("decimal")) {
return "decimal";
} else if (
lowerType.includes("timestamp") ||
lowerType.includes("datetime")
) {
return "datetime";
} else if (lowerType.includes("date")) {
return "date";
} else if (lowerType.includes("time")) {
return "datetime";
} else if (lowerType.includes("bool")) {
return "checkbox";
} else {
return "text";
} else if (
lowerType.includes("char") ||
lowerType.includes("text") ||
lowerType.includes("varchar")
) {
return lowerType.includes("text") ? "textarea" : "text";
}
// 기본값
return "text";
}
// ========================================

View File

@@ -10,6 +10,7 @@ import {
EntityJoinResponse,
EntityJoinConfig,
} from "../types/tableManagement";
import { WebType } from "../types/unified-web-types";
import { entityJoinService } from "./entityJoinService";
import { referenceCacheService } from "./referenceCacheService";
@@ -210,6 +211,11 @@ export class TableManagementService {
: null,
numericScale: column.numericScale ? Number(column.numericScale) : null,
displayOrder: column.displayOrder ? Number(column.displayOrder) : null,
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론
webType:
column.webType === "text"
? this.inferWebType(column.dataType)
: column.webType,
}));
const totalPages = Math.ceil(total / size);
@@ -2267,4 +2273,229 @@ export class TableManagementService {
return totalHitRate / cacheableJoins.length;
}
/**
* 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용)
*/
async getTableSchema(tableName: string): Promise<ColumnTypeInfo[]> {
try {
logger.info(`테이블 스키마 정보 조회: ${tableName}`);
const rawColumns = await prisma.$queryRaw<any[]>`
SELECT
column_name as "columnName",
column_name as "displayName",
data_type as "dataType",
udt_name as "dbType",
is_nullable as "isNullable",
column_default as "defaultValue",
character_maximum_length as "maxLength",
numeric_precision as "numericPrecision",
numeric_scale as "numericScale",
CASE
WHEN column_name IN (
SELECT column_name FROM information_schema.key_column_usage
WHERE table_name = ${tableName} AND constraint_name LIKE '%_pkey'
) THEN true
ELSE false
END as "isPrimaryKey"
FROM information_schema.columns
WHERE table_name = ${tableName}
AND table_schema = 'public'
ORDER BY ordinal_position
`;
const columns: ColumnTypeInfo[] = rawColumns.map((col) => ({
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType,
dbType: col.dbType,
webType: "text", // 기본값
inputType: "direct",
detailSettings: "{}",
description: "", // 필수 필드 추가
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
maxLength: col.maxLength ? Number(col.maxLength) : undefined,
numericPrecision: col.numericPrecision
? Number(col.numericPrecision)
: undefined,
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
displayOrder: 0,
isVisible: true,
}));
logger.info(
`테이블 스키마 조회 완료: ${tableName}, ${columns.length}개 컬럼`
);
return columns;
} catch (error) {
logger.error(`테이블 스키마 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
* 테이블 존재 여부 확인
*/
async checkTableExists(tableName: string): Promise<boolean> {
try {
logger.info(`테이블 존재 여부 확인: ${tableName}`);
const result = await prisma.$queryRaw<any[]>`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = ${tableName}
AND table_schema = 'public'
AND table_type = 'BASE TABLE'
) as "exists"
`;
const exists = result[0]?.exists || false;
logger.info(`테이블 존재 여부: ${tableName} = ${exists}`);
return exists;
} catch (error) {
logger.error(`테이블 존재 여부 확인 실패: ${tableName}`, error);
throw error;
}
}
/**
* 컬럼 웹타입 정보 조회 (화면관리 연동용)
*/
async getColumnWebTypes(tableName: string): Promise<ColumnTypeInfo[]> {
try {
logger.info(`컬럼 웹타입 정보 조회: ${tableName}`);
// table_type_columns에서 웹타입 정보 조회
const rawWebTypes = await prisma.$queryRaw<any[]>`
SELECT
ttc.column_name as "columnName",
ttc.column_name as "displayName",
COALESCE(ttc.web_type, 'text') as "webType",
COALESCE(ttc.detail_settings, '{}') as "detailSettings",
ttc.is_nullable as "isNullable",
ic.data_type as "dataType",
ic.udt_name as "dbType"
FROM table_type_columns ttc
LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
WHERE ttc.table_name = ${tableName}
ORDER BY ttc.display_order, ttc.column_name
`;
const webTypes: ColumnTypeInfo[] = rawWebTypes.map((col) => ({
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType || "text",
dbType: col.dbType || "text",
webType: col.webType,
inputType: "direct",
detailSettings: col.detailSettings,
description: "", // 필수 필드 추가
isNullable: col.isNullable,
isPrimaryKey: false,
displayOrder: 0,
isVisible: true,
}));
logger.info(
`컬럼 웹타입 정보 조회 완료: ${tableName}, ${webTypes.length}개 컬럼`
);
return webTypes;
} catch (error) {
logger.error(`컬럼 웹타입 정보 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
* 데이터베이스 연결 상태 확인
*/
async checkDatabaseConnection(): Promise<{
connected: boolean;
message: string;
}> {
try {
logger.info("데이터베이스 연결 상태 확인");
// 간단한 쿼리로 연결 테스트
const result = await prisma.$queryRaw<any[]>`SELECT 1 as "test"`;
if (result && result.length > 0) {
logger.info("데이터베이스 연결 성공");
return {
connected: true,
message: "데이터베이스에 성공적으로 연결되었습니다.",
};
} else {
logger.warn("데이터베이스 연결 응답 없음");
return {
connected: false,
message: "데이터베이스 연결 응답이 없습니다.",
};
}
} catch (error) {
logger.error("데이터베이스 연결 확인 실패:", error);
return {
connected: false,
message: `데이터베이스 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
};
}
}
/**
* 데이터 타입으로부터 웹타입 추론
*/
private inferWebType(dataType: string): WebType {
// 통합 타입 매핑에서 import
const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types");
const lowerType = dataType.toLowerCase();
// 정확한 매핑 우선 확인
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
return DB_TYPE_TO_WEB_TYPE[lowerType];
}
// 부분 문자열 매칭 (더 정교한 규칙)
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
if (
lowerType.includes(dbType.toLowerCase()) ||
dbType.toLowerCase().includes(lowerType)
) {
return webType as WebType;
}
}
// 추가 정밀 매핑
if (lowerType.includes("int") && !lowerType.includes("point")) {
return "number";
} else if (lowerType.includes("numeric") || lowerType.includes("decimal")) {
return "decimal";
} else if (
lowerType.includes("timestamp") ||
lowerType.includes("datetime")
) {
return "datetime";
} else if (lowerType.includes("date")) {
return "date";
} else if (lowerType.includes("time")) {
return "datetime";
} else if (lowerType.includes("bool")) {
return "checkbox";
} else if (
lowerType.includes("char") ||
lowerType.includes("text") ||
lowerType.includes("varchar")
) {
return lowerType.includes("text") ? "textarea" : "text";
}
// 기본값
return "text";
}
}

View File

@@ -4,17 +4,8 @@
export type ComponentType = "container" | "row" | "column" | "widget" | "group";
// 웹 타입 정의
export type WebType =
| "text"
| "number"
| "date"
| "code"
| "entity"
| "textarea"
| "select"
| "checkbox"
| "radio"
| "file";
// WebType은 통합 타입에서 import (중복 정의 제거)
export { WebType } from "./unified-web-types";
// 위치 정보
export interface Position {

View File

@@ -0,0 +1,406 @@
/**
* 백엔드 통합 웹 타입 정의
* 프론트엔드와 동일한 웹 타입 정의 유지
*/
// 기본 웹 타입 (프론트엔드와 동일)
export type BaseWebType =
| "text" // 일반 텍스트
| "number" // 숫자 (정수)
| "decimal" // 소수점 숫자
| "date" // 날짜
| "datetime" // 날짜시간
| "time" // 시간
| "textarea" // 여러줄 텍스트
| "select" // 선택박스
| "dropdown" // 드롭다운 (select와 동일)
| "checkbox" // 체크박스
| "radio" // 라디오버튼
| "boolean" // 불린값
| "file" // 파일 업로드
| "email" // 이메일
| "tel" // 전화번호
| "url" // URL
| "password" // 패스워드
| "code" // 공통코드 참조
| "entity" // 엔티티 참조
| "button"; // 버튼
// 레거시 지원용
export type LegacyWebType = "text_area"; // textarea와 동일
// 전체 웹 타입
export type WebType = BaseWebType | LegacyWebType;
// 동적 웹 타입 (런타임에 DB에서 로드되는 타입 포함)
export type DynamicWebType = WebType | string;
// 웹 타입 매핑 (레거시 지원)
export const WEB_TYPE_MAPPINGS: Record<LegacyWebType, BaseWebType> = {
text_area: "textarea",
};
// 웹 타입 정규화 함수
export const normalizeWebType = (webType: DynamicWebType): WebType => {
if (webType in WEB_TYPE_MAPPINGS) {
return WEB_TYPE_MAPPINGS[webType as LegacyWebType];
}
return webType as WebType;
};
// 웹 타입 검증 함수
export const isValidWebType = (webType: string): webType is WebType => {
return (
[
"text",
"number",
"decimal",
"date",
"datetime",
"time",
"textarea",
"select",
"dropdown",
"checkbox",
"radio",
"boolean",
"file",
"email",
"tel",
"url",
"password",
"code",
"entity",
"button",
"text_area", // 레거시 지원
] as string[]
).includes(webType);
};
// DB 타입과 웹 타입 매핑
export const DB_TYPE_TO_WEB_TYPE: Record<string, WebType> = {
// 텍스트 타입
"character varying": "text",
varchar: "text",
text: "textarea",
char: "text",
// 숫자 타입
integer: "number",
bigint: "number",
smallint: "number",
serial: "number",
bigserial: "number",
numeric: "decimal",
decimal: "decimal",
real: "decimal",
"double precision": "decimal",
// 날짜/시간 타입
date: "date",
timestamp: "datetime",
"timestamp with time zone": "datetime",
"timestamp without time zone": "datetime",
time: "time",
"time with time zone": "time",
"time without time zone": "time",
// 불린 타입
boolean: "boolean",
// JSON 타입 (텍스트로 처리)
json: "textarea",
jsonb: "textarea",
// 배열 타입 (텍스트로 처리)
ARRAY: "textarea",
// UUID 타입
uuid: "text",
};
// 웹 타입별 PostgreSQL 타입 변환 규칙
export const WEB_TYPE_TO_POSTGRES_CONVERTER: Record<
WebType,
(value: any) => any
> = {
text: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
number: (value) => {
if (value === null || value === undefined || value === "") return null;
const num = parseInt(String(value));
return isNaN(num) ? null : num;
},
decimal: (value) => {
if (value === null || value === undefined || value === "") return null;
const num = parseFloat(String(value));
return isNaN(num) ? null : num;
},
date: (value) => {
if (value === null || value === undefined || value === "") return null;
const date = new Date(value);
return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0];
},
datetime: (value) => {
if (value === null || value === undefined || value === "") return null;
const date = new Date(value);
return isNaN(date.getTime()) ? null : date.toISOString();
},
time: (value) => {
if (value === null || value === undefined || value === "") return null;
// 시간 형식 처리 (HH:mm:ss)
return String(value);
},
textarea: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
select: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
dropdown: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
checkbox: (value) => {
if (value === null || value === undefined) return false;
if (typeof value === "boolean") return value;
if (typeof value === "string") {
return value.toLowerCase() === "true" || value === "1" || value === "Y";
}
return Boolean(value);
},
radio: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
boolean: (value) => {
if (value === null || value === undefined) return null;
if (typeof value === "boolean") return value;
if (typeof value === "string") {
return value.toLowerCase() === "true" || value === "1" || value === "Y";
}
return Boolean(value);
},
file: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
email: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
tel: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
url: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
password: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
code: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
entity: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
button: (value) => null, // 버튼은 저장하지 않음
// 레거시 지원
text_area: (value) =>
value === null || value === undefined || value === ""
? null
: String(value),
};
// 웹 타입별 검증 규칙
export const WEB_TYPE_VALIDATION_PATTERNS: Record<WebType, RegExp | null> = {
text: null,
number: /^-?\d+$/,
decimal: /^-?\d+(\.\d+)?$/,
date: /^\d{4}-\d{2}-\d{2}$/,
datetime: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/,
time: /^\d{2}:\d{2}(:\d{2})?$/,
textarea: null,
select: null,
dropdown: null,
checkbox: null,
radio: null,
boolean: null,
file: null,
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
tel: /^(\d{2,3}-?\d{3,4}-?\d{4}|\d{10,11})$/,
url: /^https?:\/\/.+/,
password: null,
code: null,
entity: null,
button: null,
text_area: null, // 레거시 지원
};
// 업데이트된 웹 타입 옵션 (기존 WEB_TYPE_OPTIONS 대체)
export const UNIFIED_WEB_TYPE_OPTIONS = [
{
value: "text",
label: "text",
description: "일반 텍스트 입력",
category: "input",
},
{
value: "number",
label: "number",
description: "숫자 입력 (정수)",
category: "input",
},
{
value: "decimal",
label: "decimal",
description: "소수점 숫자 입력",
category: "input",
},
{
value: "date",
label: "date",
description: "날짜 선택기",
category: "input",
},
{
value: "datetime",
label: "datetime",
description: "날짜시간 선택기",
category: "input",
},
{
value: "time",
label: "time",
description: "시간 선택기",
category: "input",
},
{
value: "textarea",
label: "textarea",
description: "여러 줄 텍스트",
category: "input",
},
{
value: "select",
label: "select",
description: "선택박스",
category: "selection",
},
{
value: "dropdown",
label: "dropdown",
description: "드롭다운",
category: "selection",
},
{
value: "checkbox",
label: "checkbox",
description: "체크박스",
category: "selection",
},
{
value: "radio",
label: "radio",
description: "라디오 버튼",
category: "selection",
},
{
value: "boolean",
label: "boolean",
description: "불린값 (예/아니오)",
category: "selection",
},
{
value: "file",
label: "file",
description: "파일 업로드",
category: "upload",
},
{
value: "email",
label: "email",
description: "이메일 주소",
category: "input",
},
{ value: "tel", label: "tel", description: "전화번호", category: "input" },
{
value: "url",
label: "url",
description: "웹사이트 주소",
category: "input",
},
{
value: "password",
label: "password",
description: "비밀번호",
category: "input",
},
{
value: "code",
label: "code",
description: "코드 선택 (공통코드)",
category: "reference",
},
{
value: "entity",
label: "entity",
description: "엔티티 참조 (참조테이블)",
category: "reference",
},
{ value: "button", label: "button", description: "버튼", category: "action" },
] as const;
// 웹 타입별 기본 설정
export const WEB_TYPE_DEFAULT_CONFIGS: Record<WebType, Record<string, any>> = {
text: { maxLength: 255, placeholder: "텍스트를 입력하세요" },
number: { min: 0, max: 2147483647, step: 1 },
decimal: { min: 0, step: 0.01, decimalPlaces: 2 },
date: { format: "YYYY-MM-DD" },
datetime: { format: "YYYY-MM-DD HH:mm:ss", showTime: true },
time: { format: "HH:mm:ss" },
textarea: { rows: 4, cols: 50, maxLength: 1000 },
select: { placeholder: "선택하세요", searchable: false },
dropdown: { placeholder: "선택하세요", searchable: true },
checkbox: { defaultChecked: false },
radio: { inline: false },
boolean: { trueValue: true, falseValue: false },
file: { multiple: false, preview: true },
email: { placeholder: "이메일을 입력하세요" },
tel: { placeholder: "전화번호를 입력하세요" },
url: { placeholder: "URL을 입력하세요" },
password: { placeholder: "비밀번호를 입력하세요" },
code: { placeholder: "코드를 선택하세요", searchable: true },
entity: { placeholder: "항목을 선택하세요", searchable: true },
button: { variant: "default" },
text_area: { rows: 4, cols: 50, maxLength: 1000 }, // 레거시 지원
};