타입 관리 개선 및 화면 비율조정 중간커밋
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 삭제
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
786
backend-node/src/services/enhancedDynamicFormService.ts
Normal file
786
backend-node/src/services/enhancedDynamicFormService.ts
Normal 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();
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
406
backend-node/src/types/unified-web-types.ts
Normal file
406
backend-node/src/types/unified-web-types.ts
Normal 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 }, // 레거시 지원
|
||||
};
|
||||
Reference in New Issue
Block a user