- Added new entries to .gitignore for multi-agent MCP task queue and related rules. - Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations. - Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability. These changes aim to improve project organization and simplify the user interface by eliminating redundant options.
488 lines
14 KiB
TypeScript
488 lines
14 KiB
TypeScript
/**
|
|
* 개선된 폼 데이터 저장 서비스
|
|
* 클라이언트 측 사전 검증과 서버 측 검증을 조합한 안전한 저장 로직
|
|
*/
|
|
|
|
import { ComponentData, ColumnInfo, ScreenDefinition } from "@/types/screen";
|
|
import { validateFormData, ValidationResult } from "@/lib/utils/formValidation";
|
|
import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
|
import { screenApi } from "@/lib/api/screen";
|
|
|
|
// 저장 결과 타입
|
|
export interface EnhancedSaveResult {
|
|
success: boolean;
|
|
message: string;
|
|
data?: any;
|
|
validationResult?: ValidationResult;
|
|
performance?: {
|
|
validationTime: number;
|
|
saveTime: number;
|
|
totalTime: number;
|
|
};
|
|
warnings?: string[];
|
|
}
|
|
|
|
// 저장 옵션
|
|
export interface SaveOptions {
|
|
skipClientValidation?: boolean;
|
|
skipServerValidation?: boolean;
|
|
transformData?: boolean;
|
|
showProgress?: boolean;
|
|
retryOnError?: boolean;
|
|
maxRetries?: number;
|
|
}
|
|
|
|
// 저장 컨텍스트
|
|
export interface SaveContext {
|
|
tableName: string;
|
|
screenInfo: ScreenDefinition;
|
|
components: ComponentData[];
|
|
formData: Record<string, any>;
|
|
options?: SaveOptions;
|
|
}
|
|
|
|
/**
|
|
* 향상된 폼 데이터 저장 클래스
|
|
*/
|
|
export class EnhancedFormService {
|
|
private static instance: EnhancedFormService;
|
|
private columnCache = new Map<string, ColumnInfo[]>();
|
|
private validationCache = new Map<string, ValidationResult>();
|
|
|
|
public static getInstance(): EnhancedFormService {
|
|
if (!EnhancedFormService.instance) {
|
|
EnhancedFormService.instance = new EnhancedFormService();
|
|
}
|
|
return EnhancedFormService.instance;
|
|
}
|
|
|
|
/**
|
|
* 메인 저장 메서드
|
|
*/
|
|
async saveFormData(context: SaveContext): Promise<EnhancedSaveResult> {
|
|
const startTime = performance.now();
|
|
let validationTime = 0;
|
|
let saveTime = 0;
|
|
|
|
try {
|
|
const { tableName, screenInfo, components, formData, options = {} } = context;
|
|
|
|
console.log("🚀 향상된 폼 저장 시작:", {
|
|
tableName,
|
|
screenId: screenInfo.screenId,
|
|
dataKeys: Object.keys(formData),
|
|
componentsCount: components.length,
|
|
});
|
|
|
|
// 1. 사전 검증 수행
|
|
let validationResult: ValidationResult | undefined;
|
|
if (!options.skipClientValidation) {
|
|
const validationStart = performance.now();
|
|
validationResult = await this.performClientValidation(formData, components, tableName);
|
|
validationTime = performance.now() - validationStart;
|
|
|
|
if (!validationResult.isValid) {
|
|
console.error("❌ 클라이언트 검증 실패:", validationResult.errors);
|
|
return {
|
|
success: false,
|
|
message: this.formatValidationMessage(validationResult),
|
|
validationResult,
|
|
performance: {
|
|
validationTime,
|
|
saveTime: 0,
|
|
totalTime: performance.now() - startTime,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// 2. 데이터 변환 및 정제
|
|
let processedData = formData;
|
|
if (options.transformData !== false) {
|
|
processedData = await this.transformFormData(formData, components, tableName);
|
|
}
|
|
|
|
// 3. 서버 저장 수행
|
|
const saveStart = performance.now();
|
|
const saveResult = await this.performServerSave(screenInfo.screenId, tableName, processedData, options);
|
|
saveTime = performance.now() - saveStart;
|
|
|
|
if (!saveResult.success) {
|
|
console.error("❌ 서버 저장 실패:", saveResult.message);
|
|
return {
|
|
success: false,
|
|
message: saveResult.message || "저장 중 서버 오류가 발생했습니다.",
|
|
data: saveResult.data,
|
|
validationResult,
|
|
performance: {
|
|
validationTime,
|
|
saveTime,
|
|
totalTime: performance.now() - startTime,
|
|
},
|
|
};
|
|
}
|
|
|
|
console.log("✅ 폼 저장 성공:", {
|
|
validationTime: `${validationTime.toFixed(2)}ms`,
|
|
saveTime: `${saveTime.toFixed(2)}ms`,
|
|
totalTime: `${(performance.now() - startTime).toFixed(2)}ms`,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
message: "데이터가 성공적으로 저장되었습니다.",
|
|
data: saveResult.data,
|
|
validationResult,
|
|
performance: {
|
|
validationTime,
|
|
saveTime,
|
|
totalTime: performance.now() - startTime,
|
|
},
|
|
warnings: validationResult?.warnings.map((w) => w.message),
|
|
};
|
|
} catch (error: any) {
|
|
console.error("❌ 폼 저장 중 예외 발생:", error);
|
|
|
|
return {
|
|
success: false,
|
|
message: `저장 중 오류가 발생했습니다: ${error.message || error}`,
|
|
performance: {
|
|
validationTime,
|
|
saveTime,
|
|
totalTime: performance.now() - startTime,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 클라이언트 측 검증 수행
|
|
*/
|
|
private async performClientValidation(
|
|
formData: Record<string, any>,
|
|
components: ComponentData[],
|
|
tableName: string,
|
|
): Promise<ValidationResult> {
|
|
try {
|
|
// 캐시된 검증 결과 확인
|
|
const cacheKey = this.generateValidationCacheKey(formData, components, tableName);
|
|
const cached = this.validationCache.get(cacheKey);
|
|
if (cached) {
|
|
console.log("📋 캐시된 검증 결과 사용");
|
|
return cached;
|
|
}
|
|
|
|
// 테이블 컬럼 정보 조회 (캐시 사용)
|
|
const tableColumns = await this.getTableColumns(tableName);
|
|
|
|
// 폼 데이터 검증 수행
|
|
const validationResult = await validateFormData(formData, components, tableColumns, tableName);
|
|
|
|
// 결과 캐시 저장 (5분간)
|
|
setTimeout(
|
|
() => {
|
|
this.validationCache.delete(cacheKey);
|
|
},
|
|
5 * 60 * 1000,
|
|
);
|
|
|
|
this.validationCache.set(cacheKey, validationResult);
|
|
return validationResult;
|
|
} catch (error) {
|
|
console.error("❌ 클라이언트 검증 중 오류:", error);
|
|
return {
|
|
isValid: false,
|
|
errors: [
|
|
{
|
|
field: "validation",
|
|
code: "VALIDATION_ERROR",
|
|
message: `검증 중 오류가 발생했습니다: ${error}`,
|
|
severity: "error",
|
|
},
|
|
],
|
|
warnings: [],
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블 컬럼 정보 조회 (캐시 포함)
|
|
*/
|
|
private async getTableColumns(tableName: string): Promise<ColumnInfo[]> {
|
|
// tableName이 비어있으면 빈 배열 반환
|
|
if (!tableName || tableName.trim() === "") {
|
|
console.warn("⚠️ getTableColumns: tableName이 비어있음");
|
|
return [];
|
|
}
|
|
|
|
// 캐시 확인
|
|
const cached = this.columnCache.get(tableName);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
try {
|
|
const response = await tableManagementApi.getColumnList(tableName);
|
|
if (response.success && response.data) {
|
|
const columns = response.data.map((col) => ({
|
|
tableName: col.tableName || tableName,
|
|
columnName: col.columnName,
|
|
columnLabel: col.displayName,
|
|
dataType: col.dataType,
|
|
webType: col.webType,
|
|
inputType: col.inputType,
|
|
isNullable: col.isNullable,
|
|
required: col.isNullable === "N",
|
|
detailSettings: col.detailSettings,
|
|
codeCategory: col.codeCategory,
|
|
referenceTable: col.referenceTable,
|
|
referenceColumn: col.referenceColumn,
|
|
displayColumn: col.displayColumn,
|
|
isVisible: col.isVisible,
|
|
displayOrder: col.displayOrder,
|
|
description: col.description,
|
|
})) as ColumnInfo[];
|
|
|
|
// 캐시 저장 (10분간)
|
|
this.columnCache.set(tableName, columns);
|
|
setTimeout(
|
|
() => {
|
|
this.columnCache.delete(tableName);
|
|
},
|
|
10 * 60 * 1000,
|
|
);
|
|
|
|
return columns;
|
|
} else {
|
|
throw new Error(response.message || "컬럼 정보 조회 실패");
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ 컬럼 정보 조회 실패:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 폼 데이터 변환 및 정제
|
|
*/
|
|
private async transformFormData(
|
|
formData: Record<string, any>,
|
|
components: ComponentData[],
|
|
tableName: string,
|
|
): Promise<Record<string, any>> {
|
|
const transformed = { ...formData };
|
|
const tableColumns = await this.getTableColumns(tableName);
|
|
const columnMap = new Map(tableColumns.map((col) => [col.columnName, col]));
|
|
|
|
for (const [key, value] of Object.entries(transformed)) {
|
|
const column = columnMap.get(key);
|
|
if (!column) continue;
|
|
|
|
// 빈 문자열을 null로 변환 (nullable 컬럼인 경우)
|
|
if (value === "" && column.isNullable === "Y") {
|
|
transformed[key] = null;
|
|
continue;
|
|
}
|
|
|
|
// 데이터 타입별 변환
|
|
if (value !== null && value !== undefined) {
|
|
transformed[key] = this.convertValueByDataType(value, column.dataType);
|
|
}
|
|
}
|
|
|
|
// 시스템 필드 자동 추가
|
|
// created_date는 백엔드에서 처리하도록 프론트엔드에서 제거
|
|
// (기존 데이터 조회 시 포함된 created_date가 그대로 전송되는 문제 방지)
|
|
if (tableColumns.some((col) => col.columnName === "created_date")) {
|
|
delete transformed.created_date;
|
|
}
|
|
if (!transformed.updated_date && tableColumns.some((col) => col.columnName === "updated_date")) {
|
|
transformed.updated_date = now;
|
|
}
|
|
|
|
console.log("🔄 데이터 변환 완료:", {
|
|
original: Object.keys(formData).length,
|
|
transformed: Object.keys(transformed).length,
|
|
changes: Object.keys(transformed).filter((key) => transformed[key] !== formData[key]),
|
|
});
|
|
|
|
return transformed;
|
|
}
|
|
|
|
/**
|
|
* 데이터 타입별 값 변환
|
|
*/
|
|
private convertValueByDataType(value: any, dataType: string): any {
|
|
const lowerDataType = dataType.toLowerCase();
|
|
|
|
// 숫자 타입
|
|
if (lowerDataType.includes("integer") || lowerDataType.includes("bigint") || lowerDataType.includes("serial")) {
|
|
const num = parseInt(value);
|
|
return isNaN(num) ? null : num;
|
|
}
|
|
|
|
if (
|
|
lowerDataType.includes("numeric") ||
|
|
lowerDataType.includes("decimal") ||
|
|
lowerDataType.includes("real") ||
|
|
lowerDataType.includes("double")
|
|
) {
|
|
const num = parseFloat(value);
|
|
return isNaN(num) ? null : num;
|
|
}
|
|
|
|
// 불린 타입
|
|
if (lowerDataType.includes("boolean")) {
|
|
if (typeof value === "boolean") return value;
|
|
if (typeof value === "string") {
|
|
return value.toLowerCase() === "true" || value === "1" || value === "Y";
|
|
}
|
|
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.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
if (lowerDataType.includes("time")) {
|
|
// 시간 형식 변환 로직
|
|
return value;
|
|
}
|
|
|
|
// 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 async performServerSave(
|
|
screenId: number,
|
|
tableName: string,
|
|
formData: Record<string, any>,
|
|
options: SaveOptions,
|
|
): Promise<{ success: boolean; message?: string; data?: any }> {
|
|
try {
|
|
const result = await dynamicFormApi.saveData({
|
|
screenId,
|
|
tableName,
|
|
data: formData,
|
|
});
|
|
|
|
return {
|
|
success: result.success,
|
|
message: result.message || "저장이 완료되었습니다.",
|
|
data: result.data,
|
|
};
|
|
} catch (error: any) {
|
|
console.error("❌ 서버 저장 오류:", error);
|
|
|
|
// 에러 타입별 처리
|
|
if (error.response?.status === 400) {
|
|
return {
|
|
success: false,
|
|
message: "잘못된 요청: " + (error.response.data?.message || "데이터 형식을 확인해주세요."),
|
|
};
|
|
}
|
|
|
|
if (error.response?.status === 500) {
|
|
return {
|
|
success: false,
|
|
message: "서버 오류: " + (error.response.data?.message || "잠시 후 다시 시도해주세요."),
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
message: error.message || "저장 중 알 수 없는 오류가 발생했습니다.",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 검증 캐시 키 생성
|
|
*/
|
|
private generateValidationCacheKey(
|
|
formData: Record<string, any>,
|
|
components: ComponentData[],
|
|
tableName: string,
|
|
): string {
|
|
const dataHash = JSON.stringify(formData);
|
|
const componentsHash = JSON.stringify(
|
|
components.map((c) => ({ id: c.id, type: c.type, columnName: (c as any).columnName })),
|
|
);
|
|
return `${tableName}:${btoa(dataHash + componentsHash).substring(0, 32)}`;
|
|
}
|
|
|
|
/**
|
|
* 검증 메시지 포맷팅
|
|
*/
|
|
private formatValidationMessage(validationResult: ValidationResult): string {
|
|
const errorMessages = validationResult.errors.filter((e) => e.severity === "error").map((e) => e.message);
|
|
|
|
if (errorMessages.length === 0) {
|
|
return "알 수 없는 검증 오류가 발생했습니다.";
|
|
}
|
|
|
|
if (errorMessages.length === 1) {
|
|
return errorMessages[0];
|
|
}
|
|
|
|
return `다음 오류들을 수정해주세요:\n• ${errorMessages.join("\n• ")}`;
|
|
}
|
|
|
|
/**
|
|
* 캐시 클리어
|
|
*/
|
|
public clearCache(): void {
|
|
this.columnCache.clear();
|
|
this.validationCache.clear();
|
|
console.log("🧹 폼 서비스 캐시가 클리어되었습니다.");
|
|
}
|
|
|
|
/**
|
|
* 테이블별 캐시 클리어
|
|
*/
|
|
public clearTableCache(tableName: string): void {
|
|
this.columnCache.delete(tableName);
|
|
console.log(`🧹 테이블 '${tableName}' 캐시가 클리어되었습니다.`);
|
|
}
|
|
}
|
|
|
|
// 싱글톤 인스턴스 내보내기
|
|
export const enhancedFormService = EnhancedFormService.getInstance();
|
|
|
|
// 편의 함수들
|
|
export const saveFormDataEnhanced = (context: SaveContext): Promise<EnhancedSaveResult> => {
|
|
return enhancedFormService.saveFormData(context);
|
|
};
|
|
|
|
export const clearFormCache = (): void => {
|
|
enhancedFormService.clearCache();
|
|
};
|
|
|
|
export const clearTableFormCache = (tableName: string): void => {
|
|
enhancedFormService.clearTableCache(tableName);
|
|
};
|