664 lines
17 KiB
TypeScript
664 lines
17 KiB
TypeScript
/**
|
|
* 화면관리 폼 데이터 검증 유틸리티
|
|
* 클라이언트 측에서 사전 검증을 수행하여 사용자 경험 향상
|
|
*/
|
|
|
|
import { WebType, DynamicWebType, isValidWebType, normalizeWebType } from "@/types/unified-web-types";
|
|
import { ColumnInfo, ComponentData, WidgetComponent } from "@/types/screen";
|
|
|
|
// 검증 결과 타입
|
|
export interface ValidationResult {
|
|
isValid: boolean;
|
|
errors: ValidationError[];
|
|
warnings: ValidationWarning[];
|
|
}
|
|
|
|
export interface ValidationError {
|
|
field: string;
|
|
code: string;
|
|
message: string;
|
|
severity: "error" | "warning";
|
|
value?: any;
|
|
}
|
|
|
|
export interface ValidationWarning {
|
|
field: string;
|
|
code: string;
|
|
message: string;
|
|
suggestion?: string;
|
|
}
|
|
|
|
// 필드 검증 결과
|
|
export interface FieldValidationResult {
|
|
isValid: boolean;
|
|
error?: ValidationError;
|
|
transformedValue?: any;
|
|
}
|
|
|
|
// 스키마 검증 결과
|
|
export interface SchemaValidationResult {
|
|
isValid: boolean;
|
|
missingColumns: string[];
|
|
invalidTypes: { field: string; expected: WebType; actual: string }[];
|
|
suggestions: string[];
|
|
}
|
|
|
|
/**
|
|
* 폼 데이터 전체 검증
|
|
*/
|
|
export const validateFormData = async (
|
|
formData: Record<string, any>,
|
|
components: ComponentData[],
|
|
tableColumns: ColumnInfo[],
|
|
tableName: string,
|
|
): Promise<ValidationResult> => {
|
|
const errors: ValidationError[] = [];
|
|
const warnings: ValidationWarning[] = [];
|
|
|
|
try {
|
|
// 1. 스키마 검증 (컬럼 존재 여부, 타입 일치)
|
|
const schemaValidation = validateFormSchema(formData, components, tableColumns);
|
|
if (!schemaValidation.isValid) {
|
|
errors.push(
|
|
...schemaValidation.missingColumns.map((col) => ({
|
|
field: col,
|
|
code: "COLUMN_NOT_EXISTS",
|
|
message: `테이블 '${tableName}'에 '${col}' 컬럼이 존재하지 않습니다.`,
|
|
severity: "error" as const,
|
|
})),
|
|
);
|
|
|
|
errors.push(
|
|
...schemaValidation.invalidTypes.map((type) => ({
|
|
field: type.field,
|
|
code: "INVALID_WEB_TYPE",
|
|
message: `필드 '${type.field}'의 웹타입이 올바르지 않습니다. 예상: ${type.expected}, 실제: ${type.actual}`,
|
|
severity: "error" as const,
|
|
})),
|
|
);
|
|
}
|
|
|
|
// 2. 필수 필드 검증
|
|
const requiredValidation = validateRequiredFields(formData, components);
|
|
errors.push(...requiredValidation);
|
|
|
|
// 3. 데이터 타입 검증 및 변환
|
|
const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[];
|
|
|
|
for (const component of widgetComponents) {
|
|
const fieldName = component.columnName || component.id;
|
|
const value = formData[fieldName];
|
|
|
|
if (value !== undefined && value !== null && value !== "") {
|
|
const fieldValidation = validateFieldValue(
|
|
fieldName,
|
|
value,
|
|
component.widgetType,
|
|
component.webTypeConfig,
|
|
component.validationRules,
|
|
);
|
|
|
|
if (!fieldValidation.isValid && fieldValidation.error) {
|
|
errors.push(fieldValidation.error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. 비즈니스 로직 검증 (커스텀 규칙)
|
|
const businessValidation = await validateBusinessRules(formData, tableName, components);
|
|
errors.push(...businessValidation.errors);
|
|
warnings.push(...businessValidation.warnings);
|
|
} catch (error) {
|
|
errors.push({
|
|
field: "form",
|
|
code: "VALIDATION_ERROR",
|
|
message: `검증 중 오류가 발생했습니다: ${error}`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
|
|
return {
|
|
isValid: errors.filter((e) => e.severity === "error").length === 0,
|
|
errors,
|
|
warnings,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 스키마 검증 (컬럼 존재 여부, 타입 일치)
|
|
*/
|
|
export const validateFormSchema = (
|
|
formData: Record<string, any>,
|
|
components: ComponentData[],
|
|
tableColumns: ColumnInfo[],
|
|
): SchemaValidationResult => {
|
|
const missingColumns: string[] = [];
|
|
const invalidTypes: { field: string; expected: WebType; actual: string }[] = [];
|
|
const suggestions: string[] = [];
|
|
|
|
const columnMap = new Map(tableColumns.map((col) => [col.columnName, col]));
|
|
const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[];
|
|
|
|
for (const component of widgetComponents) {
|
|
const fieldName = component.columnName;
|
|
if (!fieldName) continue;
|
|
|
|
// 컬럼 존재 여부 확인
|
|
const columnInfo = columnMap.get(fieldName);
|
|
if (!columnInfo) {
|
|
missingColumns.push(fieldName);
|
|
|
|
// 유사한 컬럼명 제안
|
|
const similar = findSimilarColumns(fieldName, tableColumns);
|
|
if (similar.length > 0) {
|
|
suggestions.push(`'${fieldName}' 대신 '${similar.join("', '")}'을 사용하시겠습니까?`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 웹타입 일치 여부 확인
|
|
const componentWebType = normalizeWebType(component.widgetType);
|
|
const columnWebType = columnInfo.webType ? normalizeWebType(columnInfo.webType) : null;
|
|
|
|
if (columnWebType && componentWebType !== columnWebType) {
|
|
invalidTypes.push({
|
|
field: fieldName,
|
|
expected: columnWebType,
|
|
actual: componentWebType,
|
|
});
|
|
}
|
|
|
|
// 웹타입 유효성 확인
|
|
if (!isValidWebType(component.widgetType)) {
|
|
invalidTypes.push({
|
|
field: fieldName,
|
|
expected: "text", // 기본값
|
|
actual: component.widgetType,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
isValid: missingColumns.length === 0 && invalidTypes.length === 0,
|
|
missingColumns,
|
|
invalidTypes,
|
|
suggestions,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 필수 필드 검증
|
|
*/
|
|
export const validateRequiredFields = (
|
|
formData: Record<string, any>,
|
|
components: ComponentData[],
|
|
): ValidationError[] => {
|
|
const errors: ValidationError[] = [];
|
|
const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[];
|
|
|
|
for (const component of widgetComponents) {
|
|
if (!component.required) continue;
|
|
|
|
const fieldName = component.columnName || component.id;
|
|
const value = formData[fieldName];
|
|
|
|
if (
|
|
value === undefined ||
|
|
value === null ||
|
|
(typeof value === "string" && value.trim() === "") ||
|
|
(Array.isArray(value) && value.length === 0)
|
|
) {
|
|
errors.push({
|
|
field: fieldName,
|
|
code: "REQUIRED_FIELD",
|
|
message: `'${component.label || fieldName}'은(는) 필수 입력 항목입니다.`,
|
|
severity: "error",
|
|
value,
|
|
});
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
};
|
|
|
|
/**
|
|
* 개별 필드 값 검증
|
|
*/
|
|
export const validateFieldValue = (
|
|
fieldName: string,
|
|
value: any,
|
|
webType: DynamicWebType,
|
|
config?: Record<string, any>,
|
|
rules?: any[],
|
|
): FieldValidationResult => {
|
|
try {
|
|
const normalizedWebType = normalizeWebType(webType);
|
|
|
|
// 타입별 검증
|
|
switch (normalizedWebType) {
|
|
case "number":
|
|
return validateNumberField(fieldName, value, config);
|
|
|
|
case "decimal":
|
|
return validateDecimalField(fieldName, value, config);
|
|
|
|
case "date":
|
|
return validateDateField(fieldName, value, config);
|
|
|
|
case "datetime":
|
|
return validateDateTimeField(fieldName, value, config);
|
|
|
|
case "email":
|
|
return validateEmailField(fieldName, value, config);
|
|
|
|
case "tel":
|
|
return validateTelField(fieldName, value, config);
|
|
|
|
case "url":
|
|
return validateUrlField(fieldName, value, config);
|
|
|
|
case "text":
|
|
case "textarea":
|
|
return validateTextField(fieldName, value, config);
|
|
|
|
case "boolean":
|
|
case "checkbox":
|
|
return validateBooleanField(fieldName, value, config);
|
|
|
|
default:
|
|
// 기본 문자열 검증
|
|
return validateTextField(fieldName, value, config);
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "VALIDATION_ERROR",
|
|
message: `필드 '${fieldName}' 검증 중 오류: ${error}`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 숫자 필드 검증
|
|
*/
|
|
const validateNumberField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
|
const numValue = Number(value);
|
|
|
|
if (isNaN(numValue)) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "INVALID_NUMBER",
|
|
message: `'${fieldName}'에는 숫자만 입력할 수 있습니다.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (!Number.isInteger(numValue)) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "NOT_INTEGER",
|
|
message: `'${fieldName}'에는 정수만 입력할 수 있습니다.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 범위 검증
|
|
if (config?.min !== undefined && numValue < config.min) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "VALUE_TOO_SMALL",
|
|
message: `'${fieldName}'의 값은 ${config.min} 이상이어야 합니다.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (config?.max !== undefined && numValue > config.max) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "VALUE_TOO_LARGE",
|
|
message: `'${fieldName}'의 값은 ${config.max} 이하여야 합니다.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
return { isValid: true, transformedValue: numValue };
|
|
};
|
|
|
|
/**
|
|
* 소수 필드 검증
|
|
*/
|
|
const validateDecimalField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
|
const numValue = Number(value);
|
|
|
|
if (isNaN(numValue)) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "INVALID_DECIMAL",
|
|
message: `'${fieldName}'에는 숫자만 입력할 수 있습니다.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 소수점 자릿수 검증
|
|
if (config?.decimalPlaces !== undefined) {
|
|
const decimalPart = value.toString().split(".")[1];
|
|
if (decimalPart && decimalPart.length > config.decimalPlaces) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "TOO_MANY_DECIMAL_PLACES",
|
|
message: `'${fieldName}'의 소수점은 ${config.decimalPlaces}자리까지만 입력할 수 있습니다.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return { isValid: true, transformedValue: numValue };
|
|
};
|
|
|
|
/**
|
|
* 날짜 필드 검증
|
|
*/
|
|
const validateDateField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
|
const dateValue = new Date(value);
|
|
|
|
if (isNaN(dateValue.getTime())) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "INVALID_DATE",
|
|
message: `'${fieldName}'에는 올바른 날짜를 입력해주세요.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 날짜 범위 검증
|
|
if (config?.minDate) {
|
|
const minDate = new Date(config.minDate);
|
|
if (dateValue < minDate) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "DATE_TOO_EARLY",
|
|
message: `'${fieldName}'의 날짜는 ${config.minDate} 이후여야 합니다.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
if (config?.maxDate) {
|
|
const maxDate = new Date(config.maxDate);
|
|
if (dateValue > maxDate) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "DATE_TOO_LATE",
|
|
message: `'${fieldName}'의 날짜는 ${config.maxDate} 이전이어야 합니다.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return { isValid: true, transformedValue: dateValue.toISOString().split("T")[0] };
|
|
};
|
|
|
|
/**
|
|
* 날짜시간 필드 검증
|
|
*/
|
|
const validateDateTimeField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
|
const dateValue = new Date(value);
|
|
|
|
if (isNaN(dateValue.getTime())) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "INVALID_DATETIME",
|
|
message: `'${fieldName}'에는 올바른 날짜시간을 입력해주세요.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
return { isValid: true, transformedValue: dateValue.toISOString() };
|
|
};
|
|
|
|
/**
|
|
* 이메일 필드 검증
|
|
*/
|
|
const validateEmailField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
if (!emailRegex.test(value)) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "INVALID_EMAIL",
|
|
message: `'${fieldName}'에는 올바른 이메일 주소를 입력해주세요.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
return { isValid: true, transformedValue: value };
|
|
};
|
|
|
|
/**
|
|
* 전화번호 필드 검증
|
|
*/
|
|
const validateTelField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
|
// 기본 전화번호 형식 검증 (한국)
|
|
const telRegex = /^(\d{2,3}-?\d{3,4}-?\d{4}|\d{10,11})$/;
|
|
|
|
if (!telRegex.test(value.replace(/\s/g, ""))) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "INVALID_TEL",
|
|
message: `'${fieldName}'에는 올바른 전화번호를 입력해주세요.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
return { isValid: true, transformedValue: value };
|
|
};
|
|
|
|
/**
|
|
* URL 필드 검증
|
|
*/
|
|
const validateUrlField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
|
try {
|
|
new URL(value);
|
|
return { isValid: true, transformedValue: value };
|
|
} catch {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "INVALID_URL",
|
|
message: `'${fieldName}'에는 올바른 URL을 입력해주세요.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 텍스트 필드 검증
|
|
*/
|
|
const validateTextField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
|
const strValue = String(value);
|
|
|
|
// 길이 검증
|
|
if (config?.minLength && strValue.length < config.minLength) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "TOO_SHORT",
|
|
message: `'${fieldName}'은(는) 최소 ${config.minLength}자 이상이어야 합니다.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (config?.maxLength && strValue.length > config.maxLength) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "TOO_LONG",
|
|
message: `'${fieldName}'은(는) 최대 ${config.maxLength}자까지만 입력할 수 있습니다.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 패턴 검증
|
|
if (config?.pattern) {
|
|
const regex = new RegExp(config.pattern);
|
|
if (!regex.test(strValue)) {
|
|
return {
|
|
isValid: false,
|
|
error: {
|
|
field: fieldName,
|
|
code: "PATTERN_MISMATCH",
|
|
message: `'${fieldName}'의 형식이 올바르지 않습니다.`,
|
|
severity: "error",
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return { isValid: true, transformedValue: strValue };
|
|
};
|
|
|
|
/**
|
|
* 불린 필드 검증
|
|
*/
|
|
const validateBooleanField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
|
let boolValue: boolean;
|
|
|
|
if (typeof value === "boolean") {
|
|
boolValue = value;
|
|
} else if (typeof value === "string") {
|
|
boolValue = value.toLowerCase() === "true" || value === "1";
|
|
} else if (typeof value === "number") {
|
|
boolValue = value === 1;
|
|
} else {
|
|
boolValue = Boolean(value);
|
|
}
|
|
|
|
return { isValid: true, transformedValue: boolValue };
|
|
};
|
|
|
|
/**
|
|
* 비즈니스 로직 검증 (커스텀)
|
|
*/
|
|
const validateBusinessRules = async (
|
|
formData: Record<string, any>,
|
|
tableName: string,
|
|
components: ComponentData[],
|
|
): Promise<{ errors: ValidationError[]; warnings: ValidationWarning[] }> => {
|
|
const errors: ValidationError[] = [];
|
|
const warnings: ValidationWarning[] = [];
|
|
|
|
// 여기에 테이블별 비즈니스 로직 검증 추가
|
|
// 예: 중복 체크, 외래키 제약조건, 커스텀 규칙 등
|
|
|
|
return { errors, warnings };
|
|
};
|
|
|
|
/**
|
|
* 유사한 컬럼명 찾기 (오타 제안용)
|
|
*/
|
|
const findSimilarColumns = (targetColumn: string, columns: ColumnInfo[], threshold: number = 0.6): string[] => {
|
|
const similar: string[] = [];
|
|
|
|
for (const column of columns) {
|
|
const similarity = calculateStringSimilarity(targetColumn, column.columnName);
|
|
if (similarity >= threshold) {
|
|
similar.push(column.columnName);
|
|
}
|
|
}
|
|
|
|
return similar.slice(0, 3); // 최대 3개까지
|
|
};
|
|
|
|
/**
|
|
* 문자열 유사도 계산 (Levenshtein distance 기반)
|
|
*/
|
|
const calculateStringSimilarity = (str1: string, str2: string): number => {
|
|
const len1 = str1.length;
|
|
const len2 = str2.length;
|
|
|
|
const matrix: number[][] = [];
|
|
|
|
for (let i = 0; i <= len1; i++) {
|
|
matrix[i] = [i];
|
|
}
|
|
|
|
for (let j = 0; j <= len2; j++) {
|
|
matrix[0][j] = j;
|
|
}
|
|
|
|
for (let i = 1; i <= len1; i++) {
|
|
for (let j = 1; j <= len2; j++) {
|
|
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
|
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
|
}
|
|
}
|
|
|
|
const distance = matrix[len1][len2];
|
|
const maxLen = Math.max(len1, len2);
|
|
|
|
return maxLen === 0 ? 1 : (maxLen - distance) / maxLen;
|
|
};
|