feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
178
deploy/tkfb-package/api.hyungi.net/utils/access.js
Normal file
178
deploy/tkfb-package/api.hyungi.net/utils/access.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @deprecated 이 파일의 미들웨어 함수들은 하위 호환성을 위해 유지됩니다.
|
||||
* 새로운 코드에서는 '../middlewares/auth'를 사용하세요.
|
||||
*/
|
||||
|
||||
// utils/access.js - 유틸리티 함수와 레거시 호환성
|
||||
const ACCESS_LEVELS = {
|
||||
worker: 1,
|
||||
group_leader: 2,
|
||||
support_team: 3,
|
||||
admin: 4,
|
||||
system: 5
|
||||
};
|
||||
|
||||
const ACCESS_LEVEL_NAMES = {
|
||||
worker: '작업자',
|
||||
group_leader: '그룹장',
|
||||
support_team: '지원팀',
|
||||
admin: '관리자',
|
||||
system: '시스템'
|
||||
};
|
||||
|
||||
/**
|
||||
* 권한 레벨 비교 (유틸리티 함수)
|
||||
* @param {string} userLevel - 사용자의 권한 레벨
|
||||
* @param {string} requiredLevel - 필요한 권한 레벨
|
||||
* @returns {boolean} - 권한 여부
|
||||
*/
|
||||
const hasPermission = (userLevel, requiredLevel) => {
|
||||
const userOrder = ACCESS_LEVELS[userLevel] || 0;
|
||||
const requiredOrder = ACCESS_LEVELS[requiredLevel] || 999;
|
||||
return userOrder >= requiredOrder;
|
||||
};
|
||||
|
||||
/**
|
||||
* 권한 레벨을 숫자로 변환
|
||||
* @param {string} level - 권한 레벨 문자열
|
||||
* @returns {number} - 권한 레벨 숫자
|
||||
*/
|
||||
const getLevelNumber = (level) => {
|
||||
return ACCESS_LEVELS[level] || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 권한 레벨을 한글명으로 변환
|
||||
* @param {string} level - 권한 레벨 문자열
|
||||
* @returns {string} - 한글 권한명
|
||||
*/
|
||||
const getLevelName = (level) => {
|
||||
return ACCESS_LEVEL_NAMES[level] || '알 수 없음';
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용자가 특정 권한들 중 하나라도 가지고 있는지 확인
|
||||
* @param {string} userLevel - 사용자의 권한 레벨
|
||||
* @param {string[]} allowedLevels - 허용된 권한 레벨들
|
||||
* @returns {boolean} - 권한 여부
|
||||
*/
|
||||
const hasAnyPermission = (userLevel, allowedLevels) => {
|
||||
return allowedLevels.some(level => hasPermission(userLevel, level));
|
||||
};
|
||||
|
||||
/**
|
||||
* 모든 권한 레벨 목록 반환
|
||||
* @returns {string[]} - 권한 레벨 배열
|
||||
*/
|
||||
const getAllLevels = () => {
|
||||
return Object.keys(ACCESS_LEVELS);
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 권한 레벨 이상의 모든 권한 반환
|
||||
* @param {string} minLevel - 최소 권한 레벨
|
||||
* @returns {string[]} - 해당 레벨 이상의 권한들
|
||||
*/
|
||||
const getLevelsAbove = (minLevel) => {
|
||||
const minOrder = ACCESS_LEVELS[minLevel] || 0;
|
||||
return Object.keys(ACCESS_LEVELS).filter(level => ACCESS_LEVELS[level] >= minOrder);
|
||||
};
|
||||
|
||||
// ===== 프론트엔드용 권한 체크 함수들 =====
|
||||
|
||||
/**
|
||||
* 페이지 접근 권한 체크 (프론트엔드에서 사용)
|
||||
* @param {string} userLevel - 사용자 권한
|
||||
* @param {object} pageConfig - 페이지 설정 {minLevel?, allowedRoles?, deniedRoles?}
|
||||
* @returns {boolean} - 접근 가능 여부
|
||||
*/
|
||||
const canAccessPage = (userLevel, pageConfig) => {
|
||||
const { minLevel, allowedRoles, deniedRoles = [] } = pageConfig;
|
||||
|
||||
// 거부 목록 체크
|
||||
if (deniedRoles.includes(userLevel)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 허용 목록 체크
|
||||
if (allowedRoles && allowedRoles.length > 0) {
|
||||
return allowedRoles.includes(userLevel);
|
||||
}
|
||||
|
||||
// 최소 레벨 체크
|
||||
if (minLevel) {
|
||||
return hasPermission(userLevel, minLevel);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* CRUD 권한 체크 (프론트엔드에서 사용)
|
||||
* @param {string} userLevel - 사용자 권한
|
||||
* @param {string} table - 테이블명
|
||||
* @param {string} action - 액션 (CREATE, READ, UPDATE, DELETE)
|
||||
* @returns {boolean} - 권한 여부
|
||||
*/
|
||||
const canPerformAction = (userLevel, table, action) => {
|
||||
// 기본적으로 모든 인증된 사용자에게 권한 부여
|
||||
// 특별한 제한이 필요한 경우에만 여기서 체크
|
||||
|
||||
// 예시: 사용자 관리는 admin 이상만
|
||||
if (table === 'Users' && ['CREATE', 'UPDATE', 'DELETE'].includes(action)) {
|
||||
return hasPermission(userLevel, 'admin');
|
||||
}
|
||||
|
||||
// 예시: 시스템 테이블은 system만
|
||||
if (table === 'SystemConfig') {
|
||||
return hasPermission(userLevel, 'system');
|
||||
}
|
||||
|
||||
// 나머지는 모든 인증된 사용자에게 허용
|
||||
return userLevel && userLevel !== 'anonymous';
|
||||
};
|
||||
|
||||
// ===== 레거시 호환성: 미들웨어 함수 =====
|
||||
|
||||
/**
|
||||
* @deprecated 이 미들웨어는 하위 호환성을 위해 유지됩니다.
|
||||
* 새로운 코드에서는 '../middlewares/auth'의 requireRole을 사용하세요.
|
||||
*
|
||||
* @example
|
||||
* // 이전 방식 (deprecated)
|
||||
* const { requireAccess } = require('../utils/access');
|
||||
* router.get('/admin', requireAccess('admin'), handler);
|
||||
*
|
||||
* // 새로운 방식 (권장)
|
||||
* const { requireAuth, requireRole } = require('../middlewares/auth');
|
||||
* router.get('/admin', requireAuth, requireRole('admin'), handler);
|
||||
*/
|
||||
const requireAccess = (...allowed) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.user || !allowed.includes(req.user.access_level)) {
|
||||
return res.status(403).json({ error: '접근 권한이 없습니다' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// 기본 유틸리티 함수들
|
||||
hasPermission,
|
||||
getLevelNumber,
|
||||
getLevelName,
|
||||
hasAnyPermission,
|
||||
getAllLevels,
|
||||
getLevelsAbove,
|
||||
|
||||
// 프론트엔드용 함수들
|
||||
canAccessPage,
|
||||
canPerformAction,
|
||||
|
||||
// 레거시 호환성 (deprecated)
|
||||
requireAccess,
|
||||
|
||||
// 상수들
|
||||
ACCESS_LEVELS,
|
||||
ACCESS_LEVEL_NAMES
|
||||
};
|
||||
284
deploy/tkfb-package/api.hyungi.net/utils/cache.js
Normal file
284
deploy/tkfb-package/api.hyungi.net/utils/cache.js
Normal file
@@ -0,0 +1,284 @@
|
||||
// utils/cache.js - 통합 캐싱 시스템
|
||||
|
||||
const NodeCache = require('node-cache');
|
||||
|
||||
// 메모리 캐시 (Redis가 없을 때 fallback)
|
||||
const memoryCache = new NodeCache({
|
||||
stdTTL: 600, // 기본 10분
|
||||
checkperiod: 120, // 2분마다 만료된 키 정리
|
||||
useClones: false // 성능 향상을 위해 복사본 생성 안함
|
||||
});
|
||||
|
||||
// Redis 클라이언트 (선택적)
|
||||
let redisClient = null;
|
||||
|
||||
/**
|
||||
* Redis 연결 초기화 (선택적)
|
||||
*/
|
||||
const initRedis = async () => {
|
||||
try {
|
||||
const redis = require('redis');
|
||||
|
||||
redisClient = redis.createClient({
|
||||
socket: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
reconnectStrategy: (retries) => {
|
||||
if (retries > 10) {
|
||||
console.warn('Redis 재시도 횟수 초과. 메모리 캐시를 사용합니다.');
|
||||
return false; // Redis 연결 포기
|
||||
}
|
||||
return Math.min(retries * 100, 3000);
|
||||
}
|
||||
},
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
database: process.env.REDIS_DB || 0
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => {
|
||||
console.warn('Redis 오류:', err.message);
|
||||
redisClient = null; // Redis 사용 중단, 메모리 캐시로 fallback
|
||||
});
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
console.log('✅ Redis 캐시 연결 성공');
|
||||
});
|
||||
|
||||
await redisClient.connect();
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Redis 초기화 실패, 메모리 캐시 사용:', error.message);
|
||||
redisClient = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 캐시에서 값 조회
|
||||
*/
|
||||
const get = async (key) => {
|
||||
try {
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
const value = await redisClient.get(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
} else {
|
||||
return memoryCache.get(key) || null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`캐시 조회 오류 (${key}):`, error.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 캐시에 값 저장
|
||||
*/
|
||||
const set = async (key, value, ttl = 600) => {
|
||||
try {
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
await redisClient.setEx(key, ttl, JSON.stringify(value));
|
||||
} else {
|
||||
memoryCache.set(key, value, ttl);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(`캐시 저장 오류 (${key}):`, error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 캐시에서 값 삭제
|
||||
*/
|
||||
const del = async (key) => {
|
||||
try {
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
await redisClient.del(key);
|
||||
} else {
|
||||
memoryCache.del(key);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(`캐시 삭제 오류 (${key}):`, error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 패턴으로 캐시 키 삭제
|
||||
*/
|
||||
const delPattern = async (pattern) => {
|
||||
try {
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
const keys = await redisClient.keys(pattern);
|
||||
if (keys.length > 0) {
|
||||
await redisClient.del(keys);
|
||||
}
|
||||
} else {
|
||||
const keys = memoryCache.keys();
|
||||
const matchingKeys = keys.filter(key => {
|
||||
const regex = new RegExp(pattern.replace('*', '.*'));
|
||||
return regex.test(key);
|
||||
});
|
||||
memoryCache.del(matchingKeys);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(`패턴 캐시 삭제 오류 (${pattern}):`, error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 전체 캐시 초기화
|
||||
*/
|
||||
const flush = async () => {
|
||||
try {
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
await redisClient.flushDb();
|
||||
} else {
|
||||
memoryCache.flushAll();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('캐시 초기화 오류:', error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 캐시 통계 조회
|
||||
*/
|
||||
const getStats = () => {
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
return {
|
||||
type: 'redis',
|
||||
connected: true,
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: process.env.REDIS_PORT || 6379
|
||||
};
|
||||
} else {
|
||||
const stats = memoryCache.getStats();
|
||||
return {
|
||||
type: 'memory',
|
||||
connected: true,
|
||||
keys: stats.keys,
|
||||
hits: stats.hits,
|
||||
misses: stats.misses,
|
||||
hitRate: stats.hits / (stats.hits + stats.misses) || 0
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 캐시 키 생성 헬퍼
|
||||
*/
|
||||
const createKey = (prefix, ...parts) => {
|
||||
return `${prefix}:${parts.join(':')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* TTL 상수 정의
|
||||
*/
|
||||
const TTL = {
|
||||
SHORT: 60, // 1분
|
||||
MEDIUM: 300, // 5분
|
||||
LONG: 600, // 10분
|
||||
HOUR: 3600, // 1시간
|
||||
DAY: 86400 // 24시간
|
||||
};
|
||||
|
||||
/**
|
||||
* 캐시 미들웨어 생성기
|
||||
*/
|
||||
const createCacheMiddleware = (keyGenerator, ttl = TTL.MEDIUM) => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const cacheKey = typeof keyGenerator === 'function'
|
||||
? keyGenerator(req)
|
||||
: keyGenerator;
|
||||
|
||||
const cachedData = await get(cacheKey);
|
||||
|
||||
if (cachedData) {
|
||||
console.log(`🎯 캐시 히트: ${cacheKey}`);
|
||||
return res.json(cachedData);
|
||||
}
|
||||
|
||||
// 원본 res.json을 저장
|
||||
const originalJson = res.json;
|
||||
|
||||
// res.json을 오버라이드하여 응답을 캐시에 저장
|
||||
res.json = function(data) {
|
||||
// 성공 응답만 캐시
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
set(cacheKey, data, ttl).then(() => {
|
||||
console.log(`💾 캐시 저장: ${cacheKey}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 원본 응답 실행
|
||||
return originalJson.call(this, data);
|
||||
};
|
||||
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
console.warn('캐시 미들웨어 오류:', error.message);
|
||||
next();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 캐시 무효화 헬퍼
|
||||
*/
|
||||
const invalidateCache = {
|
||||
// 사용자 관련 캐시 무효화
|
||||
user: async (userId) => {
|
||||
await delPattern(`user:${userId}:*`);
|
||||
await delPattern('users:*');
|
||||
},
|
||||
|
||||
// 작업자 관련 캐시 무효화
|
||||
worker: async (workerId) => {
|
||||
await delPattern(`worker:${workerId}:*`);
|
||||
await delPattern('workers:*');
|
||||
},
|
||||
|
||||
// 프로젝트 관련 캐시 무효화
|
||||
project: async (projectId) => {
|
||||
await delPattern(`project:${projectId}:*`);
|
||||
await delPattern('projects:*');
|
||||
},
|
||||
|
||||
// 작업 관련 캐시 무효화
|
||||
task: async (taskId) => {
|
||||
await delPattern(`task:${taskId}:*`);
|
||||
await delPattern('tasks:*');
|
||||
},
|
||||
|
||||
// 일일 작업 보고서 관련 캐시 무효화
|
||||
dailyWorkReport: async (date) => {
|
||||
await delPattern(`daily-work-report:${date}:*`);
|
||||
await delPattern('daily-work-reports:*');
|
||||
},
|
||||
|
||||
// 전체 캐시 무효화
|
||||
all: async () => {
|
||||
await flush();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
initRedis,
|
||||
get,
|
||||
set,
|
||||
del,
|
||||
delPattern,
|
||||
flush,
|
||||
getStats,
|
||||
createKey,
|
||||
TTL,
|
||||
createCacheMiddleware,
|
||||
invalidateCache
|
||||
};
|
||||
131
deploy/tkfb-package/api.hyungi.net/utils/dateUtils.js
Normal file
131
deploy/tkfb-package/api.hyungi.net/utils/dateUtils.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 날짜/시간 유틸리티 함수
|
||||
*
|
||||
* 중요: 이 프로젝트는 한국(Asia/Seoul, UTC+9) 시간대 기준으로 운영됩니다.
|
||||
* DB에 저장되는 비즈니스 날짜(report_date, session_date 등)는 반드시 한국 시간 기준이어야 합니다.
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2026-02-03
|
||||
*/
|
||||
|
||||
const KOREA_TIMEZONE_OFFSET = 9; // UTC+9
|
||||
|
||||
/**
|
||||
* 한국 시간(KST) 기준 현재 Date 객체 반환
|
||||
* @returns {Date} 한국 시간 기준 Date 객체
|
||||
*/
|
||||
function getKoreaDate() {
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 시간(KST) 기준 현재 날짜 문자열 반환
|
||||
* @returns {string} 'YYYY-MM-DD' 형식
|
||||
*/
|
||||
function getKoreaDateString() {
|
||||
const koreaDate = getKoreaDate();
|
||||
const year = koreaDate.getUTCFullYear();
|
||||
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 시간(KST) 기준 현재 datetime 문자열 반환
|
||||
* @returns {string} 'YYYY-MM-DD HH:mm:ss' 형식 (MySQL DATETIME 호환)
|
||||
*/
|
||||
function getKoreaDatetime() {
|
||||
const koreaDate = getKoreaDate();
|
||||
const year = koreaDate.getUTCFullYear();
|
||||
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 시간(KST) 기준 현재 시간 문자열 반환
|
||||
* @returns {string} 'HH:mm:ss' 형식
|
||||
*/
|
||||
function getKoreaTimeString() {
|
||||
const koreaDate = getKoreaDate();
|
||||
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC Date를 한국 시간 datetime 문자열로 변환
|
||||
* @param {Date} date - UTC 기준 Date 객체
|
||||
* @returns {string} 'YYYY-MM-DD HH:mm:ss' 형식
|
||||
*/
|
||||
function toKoreaDatetime(date) {
|
||||
if (!date) return null;
|
||||
const koreaDate = new Date(date.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
|
||||
const year = koreaDate.getUTCFullYear();
|
||||
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC Date를 한국 날짜 문자열로 변환
|
||||
* @param {Date} date - UTC 기준 Date 객체
|
||||
* @returns {string} 'YYYY-MM-DD' 형식
|
||||
*/
|
||||
function toKoreaDateString(date) {
|
||||
if (!date) return null;
|
||||
const koreaDate = new Date(date.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
|
||||
const year = koreaDate.getUTCFullYear();
|
||||
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 시간 datetime 문자열을 Date 객체로 변환
|
||||
* @param {string} koreaDatetimeStr - 'YYYY-MM-DD HH:mm:ss' 형식
|
||||
* @returns {Date} UTC 기준 Date 객체
|
||||
*/
|
||||
function fromKoreaDatetime(koreaDatetimeStr) {
|
||||
if (!koreaDatetimeStr) return null;
|
||||
// 한국 시간 문자열을 UTC로 변환
|
||||
const date = new Date(koreaDatetimeStr + '+09:00');
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연도 반환 (한국 시간 기준)
|
||||
* @returns {number} 현재 연도
|
||||
*/
|
||||
function getKoreaYear() {
|
||||
return getKoreaDate().getUTCFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 월 반환 (한국 시간 기준, 1-12)
|
||||
* @returns {number} 현재 월 (1-12)
|
||||
*/
|
||||
function getKoreaMonth() {
|
||||
return getKoreaDate().getUTCMonth() + 1;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KOREA_TIMEZONE_OFFSET,
|
||||
getKoreaDate,
|
||||
getKoreaDateString,
|
||||
getKoreaDatetime,
|
||||
getKoreaTimeString,
|
||||
toKoreaDatetime,
|
||||
toKoreaDateString,
|
||||
fromKoreaDatetime,
|
||||
getKoreaYear,
|
||||
getKoreaMonth,
|
||||
};
|
||||
143
deploy/tkfb-package/api.hyungi.net/utils/errorHandler.js
Normal file
143
deploy/tkfb-package/api.hyungi.net/utils/errorHandler.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// utils/errorHandler.js - 통합 에러 처리 유틸리티
|
||||
|
||||
/**
|
||||
* 표준화된 에러 응답 생성
|
||||
*/
|
||||
class ApiError extends Error {
|
||||
constructor(message, statusCode = 500, errorCode = null) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.errorCode = errorCode;
|
||||
this.timestamp = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 응답 포맷터
|
||||
*/
|
||||
const formatErrorResponse = (error, req = null) => {
|
||||
const response = {
|
||||
success: false,
|
||||
error: error.message || '알 수 없는 오류가 발생했습니다.',
|
||||
timestamp: error.timestamp || new Date().toISOString()
|
||||
};
|
||||
|
||||
// 개발 환경에서만 상세 정보 포함
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
response.stack = error.stack;
|
||||
response.errorCode = error.errorCode;
|
||||
if (req) {
|
||||
response.requestInfo = {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
userAgent: req.get('User-Agent'),
|
||||
ip: req.ip
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 데이터베이스 에러 처리
|
||||
*/
|
||||
const handleDatabaseError = (error, operation = 'database operation') => {
|
||||
console.error(`[DB Error] ${operation}:`, error);
|
||||
|
||||
// 일반적인 DB 에러 코드 매핑
|
||||
const errorMappings = {
|
||||
'ER_DUP_ENTRY': { message: '중복된 데이터입니다.', statusCode: 409 },
|
||||
'ER_NO_REFERENCED_ROW_2': { message: '참조된 데이터가 존재하지 않습니다.', statusCode: 400 },
|
||||
'ER_ROW_IS_REFERENCED_2': { message: '다른 데이터에서 참조되고 있어 삭제할 수 없습니다.', statusCode: 409 },
|
||||
'ER_BAD_FIELD_ERROR': { message: '잘못된 필드명입니다.', statusCode: 400 },
|
||||
'ER_NO_SUCH_TABLE': { message: '테이블이 존재하지 않습니다.', statusCode: 500 },
|
||||
'ECONNREFUSED': { message: '데이터베이스 연결에 실패했습니다.', statusCode: 503 }
|
||||
};
|
||||
|
||||
const mapping = errorMappings[error.code] || errorMappings[error.errno];
|
||||
if (mapping) {
|
||||
throw new ApiError(mapping.message, mapping.statusCode, error.code);
|
||||
}
|
||||
|
||||
// 기본 에러
|
||||
throw new ApiError(`${operation} 중 오류가 발생했습니다.`, 500, error.code);
|
||||
};
|
||||
|
||||
/**
|
||||
* 유효성 검사 에러 처리
|
||||
*/
|
||||
const handleValidationError = (field, value, rule) => {
|
||||
const message = `${field} 필드가 유효하지 않습니다. (값: ${value}, 규칙: ${rule})`;
|
||||
throw new ApiError(message, 400, 'VALIDATION_ERROR');
|
||||
};
|
||||
|
||||
/**
|
||||
* 권한 에러 처리
|
||||
*/
|
||||
const handleAuthorizationError = (requiredLevel, userLevel) => {
|
||||
const message = `접근 권한이 부족합니다. (필요: ${requiredLevel}, 현재: ${userLevel})`;
|
||||
throw new ApiError(message, 403, 'AUTHORIZATION_ERROR');
|
||||
};
|
||||
|
||||
/**
|
||||
* 리소스 없음 에러 처리
|
||||
*/
|
||||
const handleNotFoundError = (resource, identifier = null) => {
|
||||
const message = identifier
|
||||
? `${resource}(${identifier})을(를) 찾을 수 없습니다.`
|
||||
: `${resource}을(를) 찾을 수 없습니다.`;
|
||||
throw new ApiError(message, 404, 'NOT_FOUND');
|
||||
};
|
||||
|
||||
/**
|
||||
* Express 에러 핸들러 미들웨어
|
||||
*/
|
||||
const errorMiddleware = (error, req, res, next) => {
|
||||
// ApiError가 아닌 경우 변환
|
||||
if (!(error instanceof ApiError)) {
|
||||
error = new ApiError(error.message || '서버 내부 오류', 500);
|
||||
}
|
||||
|
||||
const response = formatErrorResponse(error, req);
|
||||
|
||||
// 로깅
|
||||
if (error.statusCode >= 500) {
|
||||
console.error('[Server Error]', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
user: req.user?.username || 'anonymous'
|
||||
});
|
||||
} else {
|
||||
console.warn('[Client Error]', {
|
||||
message: error.message,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
user: req.user?.username || 'anonymous'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(error.statusCode).json(response);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비동기 함수 래퍼 (에러 자동 처리)
|
||||
*/
|
||||
const asyncHandler = (fn) => {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
ApiError,
|
||||
formatErrorResponse,
|
||||
handleDatabaseError,
|
||||
handleValidationError,
|
||||
handleAuthorizationError,
|
||||
handleNotFoundError,
|
||||
errorMiddleware,
|
||||
asyncHandler
|
||||
};
|
||||
186
deploy/tkfb-package/api.hyungi.net/utils/errors.js
Normal file
186
deploy/tkfb-package/api.hyungi.net/utils/errors.js
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 커스텀 에러 클래스
|
||||
*
|
||||
* 애플리케이션 전체에서 사용하는 표준화된 에러 클래스들
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
/**
|
||||
* 기본 애플리케이션 에러 클래스
|
||||
* 모든 커스텀 에러의 부모 클래스
|
||||
*/
|
||||
class AppError extends Error {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {number} statusCode - HTTP 상태 코드
|
||||
* @param {string} code - 에러 코드 (예: 'VALIDATION_ERROR')
|
||||
* @param {object} details - 추가 세부 정보
|
||||
*/
|
||||
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
this.isOperational = true; // 운영 에러 (예상된 에러)
|
||||
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 형태로 에러 정보 반환
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
statusCode: this.statusCode,
|
||||
code: this.code,
|
||||
details: this.details
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 에러 (400 Bad Request)
|
||||
* 입력값 검증 실패 시 사용
|
||||
*/
|
||||
class ValidationError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {object} details - 검증 실패 세부 정보
|
||||
*/
|
||||
constructor(message = '입력값이 올바르지 않습니다', details = null) {
|
||||
super(message, 400, 'VALIDATION_ERROR', details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 에러 (401 Unauthorized)
|
||||
* 인증이 필요하거나 인증 실패 시 사용
|
||||
*/
|
||||
class AuthenticationError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
*/
|
||||
constructor(message = '인증이 필요합니다') {
|
||||
super(message, 401, 'AUTHENTICATION_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 에러 (403 Forbidden)
|
||||
* 권한이 부족할 때 사용
|
||||
*/
|
||||
class ForbiddenError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
*/
|
||||
constructor(message = '권한이 없습니다') {
|
||||
super(message, 403, 'FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 없음 에러 (404 Not Found)
|
||||
* 요청한 리소스를 찾을 수 없을 때 사용
|
||||
*/
|
||||
class NotFoundError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {string} resource - 찾을 수 없는 리소스명
|
||||
*/
|
||||
constructor(message = '리소스를 찾을 수 없습니다', resource = null) {
|
||||
super(message, 404, 'NOT_FOUND', resource ? { resource } : null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 충돌 에러 (409 Conflict)
|
||||
* 중복된 리소스 등 충돌 발생 시 사용
|
||||
*/
|
||||
class ConflictError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {object} details - 충돌 세부 정보
|
||||
*/
|
||||
constructor(message = '이미 존재하는 데이터입니다', details = null) {
|
||||
super(message, 409, 'CONFLICT', details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 에러 (500 Internal Server Error)
|
||||
* 예상하지 못한 서버 오류 시 사용
|
||||
*/
|
||||
class InternalServerError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
*/
|
||||
constructor(message = '서버 오류가 발생했습니다') {
|
||||
super(message, 500, 'INTERNAL_SERVER_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 에러 (500 Internal Server Error)
|
||||
* DB 관련 오류 시 사용
|
||||
*/
|
||||
class DatabaseError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {Error} originalError - 원본 DB 에러
|
||||
*/
|
||||
constructor(message = '데이터베이스 오류가 발생했습니다', originalError = null) {
|
||||
super(
|
||||
message,
|
||||
500,
|
||||
'DATABASE_ERROR',
|
||||
originalError ? { originalMessage: originalError.message } : null
|
||||
);
|
||||
this.originalError = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 에러 (502 Bad Gateway)
|
||||
* 외부 서비스 호출 실패 시 사용
|
||||
*/
|
||||
class ExternalApiError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {string} service - 외부 서비스명
|
||||
*/
|
||||
constructor(message = '외부 서비스 호출에 실패했습니다', service = null) {
|
||||
super(message, 502, 'EXTERNAL_API_ERROR', service ? { service } : null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임아웃 에러 (504 Gateway Timeout)
|
||||
* 요청 처리 시간 초과 시 사용
|
||||
*/
|
||||
class TimeoutError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {number} timeout - 타임아웃 시간 (ms)
|
||||
*/
|
||||
constructor(message = '요청 처리 시간이 초과되었습니다', timeout = null) {
|
||||
super(message, 504, 'TIMEOUT_ERROR', timeout ? { timeout } : null);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AppError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
InternalServerError,
|
||||
DatabaseError,
|
||||
ExternalApiError,
|
||||
TimeoutError
|
||||
};
|
||||
315
deploy/tkfb-package/api.hyungi.net/utils/fileUploadSecurity.js
Normal file
315
deploy/tkfb-package/api.hyungi.net/utils/fileUploadSecurity.js
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* File Upload Security - 파일 업로드 보안 유틸리티
|
||||
*
|
||||
* - Magic number (파일 시그니처) 검증
|
||||
* - 파일명 sanitize
|
||||
* - 확장자 화이트리스트 검증
|
||||
* - 파일 크기 제한
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2026-02-04
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
/**
|
||||
* 파일 시그니처 (Magic Numbers)
|
||||
* 파일의 실제 타입을 확인하기 위한 바이너리 시그니처
|
||||
*/
|
||||
const FILE_SIGNATURES = {
|
||||
// 이미지
|
||||
'ffd8ff': { mime: 'image/jpeg', ext: ['.jpg', '.jpeg'] },
|
||||
'89504e47': { mime: 'image/png', ext: ['.png'] },
|
||||
'47494638': { mime: 'image/gif', ext: ['.gif'] },
|
||||
'52494646': { mime: 'image/webp', ext: ['.webp'] }, // RIFF (WebP 시작)
|
||||
|
||||
// 문서
|
||||
'25504446': { mime: 'application/pdf', ext: ['.pdf'] },
|
||||
'504b0304': { mime: 'application/zip', ext: ['.zip', '.xlsx', '.docx', '.pptx'] },
|
||||
|
||||
// 주의: BMP, TIFF 등 추가 가능
|
||||
};
|
||||
|
||||
/**
|
||||
* 허용된 이미지 확장자
|
||||
*/
|
||||
const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
|
||||
/**
|
||||
* 허용된 문서 확장자
|
||||
*/
|
||||
const ALLOWED_DOCUMENT_EXTENSIONS = ['.pdf', '.xlsx', '.docx', '.pptx', '.zip'];
|
||||
|
||||
/**
|
||||
* 위험한 확장자 (절대 허용 안 함)
|
||||
*/
|
||||
const DANGEROUS_EXTENSIONS = [
|
||||
'.exe', '.bat', '.cmd', '.sh', '.ps1', '.vbs', '.js', '.jar',
|
||||
'.php', '.asp', '.aspx', '.jsp', '.cgi', '.pl', '.py', '.rb',
|
||||
'.htaccess', '.htpasswd', '.ini', '.config', '.env'
|
||||
];
|
||||
|
||||
/**
|
||||
* 파일 시그니처(Magic Number) 검증
|
||||
*
|
||||
* @param {Buffer} buffer - 파일 버퍼 (최소 8바이트)
|
||||
* @returns {Object|null} 매칭된 파일 정보 또는 null
|
||||
*/
|
||||
const checkMagicNumber = (buffer) => {
|
||||
if (!buffer || buffer.length < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 처음 8바이트를 hex로 변환
|
||||
const hex = buffer.slice(0, 8).toString('hex').toLowerCase();
|
||||
|
||||
// 시그니처 매칭
|
||||
for (const [signature, info] of Object.entries(FILE_SIGNATURES)) {
|
||||
if (hex.startsWith(signature)) {
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 버퍼에서 실제 MIME 타입 검증
|
||||
*
|
||||
* @param {Buffer} buffer - 파일 버퍼
|
||||
* @param {string} declaredMime - 선언된 MIME 타입
|
||||
* @returns {Object} { valid: boolean, actualType: string|null, message: string }
|
||||
*/
|
||||
const validateFileType = (buffer, declaredMime) => {
|
||||
const detected = checkMagicNumber(buffer);
|
||||
|
||||
if (!detected) {
|
||||
return {
|
||||
valid: false,
|
||||
actualType: null,
|
||||
message: '알 수 없는 파일 형식입니다.'
|
||||
};
|
||||
}
|
||||
|
||||
// MIME 타입이 일치하는지 확인
|
||||
if (detected.mime !== declaredMime) {
|
||||
return {
|
||||
valid: false,
|
||||
actualType: detected.mime,
|
||||
message: `파일 형식이 일치하지 않습니다. (선언: ${declaredMime}, 실제: ${detected.mime})`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
actualType: detected.mime,
|
||||
message: 'OK'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일명 sanitize
|
||||
* 경로 조작 및 특수문자 제거
|
||||
*
|
||||
* @param {string} filename - 원본 파일명
|
||||
* @returns {string} 안전한 파일명
|
||||
*/
|
||||
const sanitizeFilename = (filename) => {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return 'unnamed';
|
||||
}
|
||||
|
||||
// 경로 구분자 제거 (path traversal 방지)
|
||||
let safe = path.basename(filename);
|
||||
|
||||
// 특수문자 제거 (영문, 숫자, -, _, . 만 허용)
|
||||
safe = safe.replace(/[^a-zA-Z0-9가-힣._-]/g, '_');
|
||||
|
||||
// 연속된 점 제거 (이중 확장자 방지)
|
||||
safe = safe.replace(/\.{2,}/g, '.');
|
||||
|
||||
// 앞뒤 점/공백 제거
|
||||
safe = safe.replace(/^[\s.]+|[\s.]+$/g, '');
|
||||
|
||||
// 빈 파일명 처리
|
||||
if (!safe || safe === '') {
|
||||
safe = 'unnamed';
|
||||
}
|
||||
|
||||
// 최대 길이 제한 (255자)
|
||||
if (safe.length > 255) {
|
||||
const ext = path.extname(safe);
|
||||
const name = path.basename(safe, ext);
|
||||
safe = name.slice(0, 255 - ext.length) + ext;
|
||||
}
|
||||
|
||||
return safe;
|
||||
};
|
||||
|
||||
/**
|
||||
* 확장자 검증
|
||||
*
|
||||
* @param {string} filename - 파일명
|
||||
* @param {string[]} allowedExtensions - 허용된 확장자 배열
|
||||
* @returns {Object} { valid: boolean, extension: string, message: string }
|
||||
*/
|
||||
const validateExtension = (filename, allowedExtensions = ALLOWED_IMAGE_EXTENSIONS) => {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
|
||||
// 위험한 확장자 체크
|
||||
if (DANGEROUS_EXTENSIONS.includes(ext)) {
|
||||
return {
|
||||
valid: false,
|
||||
extension: ext,
|
||||
message: `보안상 허용되지 않는 파일 형식입니다: ${ext}`
|
||||
};
|
||||
}
|
||||
|
||||
// 허용된 확장자 체크
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
return {
|
||||
valid: false,
|
||||
extension: ext,
|
||||
message: `허용된 파일 형식: ${allowedExtensions.join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
extension: ext,
|
||||
message: 'OK'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 안전한 랜덤 파일명 생성
|
||||
*
|
||||
* @param {string} originalFilename - 원본 파일명 (확장자 추출용)
|
||||
* @returns {string} 랜덤 파일명
|
||||
*/
|
||||
const generateSafeFilename = (originalFilename) => {
|
||||
const ext = path.extname(originalFilename).toLowerCase();
|
||||
const randomName = crypto.randomBytes(16).toString('hex');
|
||||
const timestamp = Date.now();
|
||||
|
||||
return `${timestamp}_${randomName}${ext}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 안전한 업로드 경로 생성
|
||||
* 경로 조작(path traversal) 방지
|
||||
*
|
||||
* @param {string} baseDir - 기본 업로드 디렉토리
|
||||
* @param {string} filename - 파일명
|
||||
* @returns {string} 안전한 전체 경로
|
||||
*/
|
||||
const getSafeUploadPath = (baseDir, filename) => {
|
||||
const safeName = sanitizeFilename(filename);
|
||||
const fullPath = path.join(baseDir, safeName);
|
||||
|
||||
// 결과 경로가 baseDir 안에 있는지 확인
|
||||
const resolvedBase = path.resolve(baseDir);
|
||||
const resolvedFull = path.resolve(fullPath);
|
||||
|
||||
if (!resolvedFull.startsWith(resolvedBase)) {
|
||||
throw new Error('경로 조작이 감지되었습니다.');
|
||||
}
|
||||
|
||||
return resolvedFull;
|
||||
};
|
||||
|
||||
/**
|
||||
* Multer 파일 필터 생성
|
||||
*
|
||||
* @param {Object} options - 옵션
|
||||
* @param {string[]} options.allowedExtensions - 허용된 확장자
|
||||
* @param {string[]} options.allowedMimes - 허용된 MIME 타입
|
||||
* @param {boolean} options.checkMagicNumber - Magic number 검증 여부
|
||||
* @returns {Function} Multer fileFilter 함수
|
||||
*/
|
||||
const createFileFilter = (options = {}) => {
|
||||
const {
|
||||
allowedExtensions = ALLOWED_IMAGE_EXTENSIONS,
|
||||
allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
checkMagicNumber = false // Multer에서는 버퍼 접근이 제한적이므로 기본 false
|
||||
} = options;
|
||||
|
||||
return (req, file, cb) => {
|
||||
// 확장자 검증
|
||||
const extResult = validateExtension(file.originalname, allowedExtensions);
|
||||
if (!extResult.valid) {
|
||||
return cb(new Error(extResult.message), false);
|
||||
}
|
||||
|
||||
// MIME 타입 검증
|
||||
if (!allowedMimes.includes(file.mimetype)) {
|
||||
return cb(new Error(`허용된 MIME 타입: ${allowedMimes.join(', ')}`), false);
|
||||
}
|
||||
|
||||
cb(null, true);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 업로드된 파일 검증 (후처리용)
|
||||
* Multer 업로드 후 파일 내용을 검증
|
||||
*
|
||||
* @param {string} filePath - 업로드된 파일 경로
|
||||
* @param {string} declaredMime - 선언된 MIME 타입
|
||||
* @returns {Promise<Object>} 검증 결과
|
||||
*/
|
||||
const validateUploadedFile = async (filePath, declaredMime) => {
|
||||
try {
|
||||
// 파일 시작 부분 읽기
|
||||
const fd = await fs.open(filePath, 'r');
|
||||
const buffer = Buffer.alloc(8);
|
||||
await fd.read(buffer, 0, 8, 0);
|
||||
await fd.close();
|
||||
|
||||
// Magic number 검증
|
||||
const typeResult = validateFileType(buffer, declaredMime);
|
||||
|
||||
if (!typeResult.valid) {
|
||||
// 위험한 파일이면 삭제
|
||||
await fs.unlink(filePath);
|
||||
return {
|
||||
valid: false,
|
||||
deleted: true,
|
||||
message: typeResult.message
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
deleted: false,
|
||||
message: 'OK',
|
||||
actualType: typeResult.actualType
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
deleted: false,
|
||||
message: `파일 검증 중 오류: ${error.message}`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// 상수
|
||||
ALLOWED_IMAGE_EXTENSIONS,
|
||||
ALLOWED_DOCUMENT_EXTENSIONS,
|
||||
DANGEROUS_EXTENSIONS,
|
||||
FILE_SIGNATURES,
|
||||
|
||||
// 함수
|
||||
checkMagicNumber,
|
||||
validateFileType,
|
||||
sanitizeFilename,
|
||||
validateExtension,
|
||||
generateSafeFilename,
|
||||
getSafeUploadPath,
|
||||
createFileFilter,
|
||||
validateUploadedFile
|
||||
};
|
||||
166
deploy/tkfb-package/api.hyungi.net/utils/hangulToRoman.js
Normal file
166
deploy/tkfb-package/api.hyungi.net/utils/hangulToRoman.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 한글 이름을 영문(로마자)으로 변환하는 유틸리티
|
||||
*
|
||||
* 사용 예시:
|
||||
* hangulToRoman('홍길동') => 'hong.gildong'
|
||||
* hangulToRoman('김철수') => 'kim.cheolsu'
|
||||
*/
|
||||
|
||||
// 한글 로마자 변환 매핑 (국립국어원 표기법 기준)
|
||||
const CHOSUNG_MAP = {
|
||||
'ㄱ': 'g', 'ㄲ': 'kk', 'ㄴ': 'n', 'ㄷ': 'd', 'ㄸ': 'tt',
|
||||
'ㄹ': 'r', 'ㅁ': 'm', 'ㅂ': 'b', 'ㅃ': 'pp', 'ㅅ': 's',
|
||||
'ㅆ': 'ss', 'ㅇ': '', 'ㅈ': 'j', 'ㅉ': 'jj', 'ㅊ': 'ch',
|
||||
'ㅋ': 'k', 'ㅌ': 't', 'ㅍ': 'p', 'ㅎ': 'h'
|
||||
};
|
||||
|
||||
const JUNGSUNG_MAP = {
|
||||
'ㅏ': 'a', 'ㅐ': 'ae', 'ㅑ': 'ya', 'ㅒ': 'yae', 'ㅓ': 'eo',
|
||||
'ㅔ': 'e', 'ㅕ': 'yeo', 'ㅖ': 'ye', 'ㅗ': 'o', 'ㅘ': 'wa',
|
||||
'ㅙ': 'wae', 'ㅚ': 'oe', 'ㅛ': 'yo', 'ㅜ': 'u', 'ㅝ': 'wo',
|
||||
'ㅞ': 'we', 'ㅟ': 'wi', 'ㅠ': 'yu', 'ㅡ': 'eu', 'ㅢ': 'ui',
|
||||
'ㅣ': 'i'
|
||||
};
|
||||
|
||||
const JONGSUNG_MAP = {
|
||||
'': '', 'ㄱ': 'k', 'ㄲ': 'k', 'ㄳ': 'k', 'ㄴ': 'n', 'ㄵ': 'n',
|
||||
'ㄶ': 'n', 'ㄷ': 't', 'ㄹ': 'l', 'ㄺ': 'k', 'ㄻ': 'm', 'ㄼ': 'p',
|
||||
'ㄽ': 'l', 'ㄾ': 'l', 'ㄿ': 'p', 'ㅀ': 'l', 'ㅁ': 'm', 'ㅂ': 'p',
|
||||
'ㅄ': 'p', 'ㅅ': 't', 'ㅆ': 't', 'ㅇ': 'ng', 'ㅈ': 't', 'ㅊ': 't',
|
||||
'ㅋ': 'k', 'ㅌ': 't', 'ㅍ': 'p', 'ㅎ': 't'
|
||||
};
|
||||
|
||||
const CHOSUNG = [
|
||||
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
|
||||
'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
|
||||
];
|
||||
|
||||
const JUNGSUNG = [
|
||||
'ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ',
|
||||
'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'
|
||||
];
|
||||
|
||||
const JONGSUNG = [
|
||||
'', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ',
|
||||
'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ',
|
||||
'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
|
||||
];
|
||||
|
||||
/**
|
||||
* 한글 한 글자를 초성, 중성, 종성으로 분리
|
||||
* @param {string} char - 한글 한 글자
|
||||
* @returns {object} { cho, jung, jong }
|
||||
*/
|
||||
function decomposeHangul(char) {
|
||||
const code = char.charCodeAt(0) - 0xAC00;
|
||||
|
||||
if (code < 0 || code > 11171) {
|
||||
return null; // 한글이 아님
|
||||
}
|
||||
|
||||
const choIndex = Math.floor(code / 588);
|
||||
const jungIndex = Math.floor((code % 588) / 28);
|
||||
const jongIndex = code % 28;
|
||||
|
||||
return {
|
||||
cho: CHOSUNG[choIndex],
|
||||
jung: JUNGSUNG[jungIndex],
|
||||
jong: JONGSUNG[jongIndex]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글 이름을 로마자로 변환
|
||||
* @param {string} koreanName - 한글 이름
|
||||
* @returns {string} 로마자 이름 (예: 'hong.gildong')
|
||||
*/
|
||||
function hangulToRoman(koreanName) {
|
||||
if (!koreanName || typeof koreanName !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 공백 제거
|
||||
const trimmed = koreanName.trim();
|
||||
|
||||
// 성과 이름 분리 (첫 글자를 성으로 간주)
|
||||
const surname = trimmed[0];
|
||||
const givenName = trimmed.substring(1);
|
||||
|
||||
// 각 부분을 로마자로 변환
|
||||
const romanSurname = convertToRoman(surname);
|
||||
const romanGivenName = convertToRoman(givenName);
|
||||
|
||||
// 점(.)으로 연결
|
||||
return `${romanSurname}.${romanGivenName}`.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글 문자열을 로마자로 변환
|
||||
* @param {string} text - 한글 문자열
|
||||
* @returns {string} 로마자 문자열
|
||||
*/
|
||||
function convertToRoman(text) {
|
||||
let result = '';
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const decomposed = decomposeHangul(char);
|
||||
|
||||
if (decomposed) {
|
||||
result += CHOSUNG_MAP[decomposed.cho] || '';
|
||||
result += JUNGSUNG_MAP[decomposed.jung] || '';
|
||||
result += JONGSUNG_MAP[decomposed.jong] || '';
|
||||
} else {
|
||||
// 한글이 아닌 경우 그대로 추가
|
||||
result += char;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자명 생성 (중복 확인 및 처리)
|
||||
* @param {string} koreanName - 한글 이름
|
||||
* @param {object} db - Database connection (mysql2 pool or knex)
|
||||
* @returns {Promise<string>} 고유한 username
|
||||
*/
|
||||
async function generateUniqueUsername(koreanName, db) {
|
||||
const baseUsername = hangulToRoman(koreanName);
|
||||
let username = baseUsername;
|
||||
let counter = 1;
|
||||
|
||||
// 중복 확인
|
||||
while (true) {
|
||||
let existing;
|
||||
|
||||
// mysql2 pool 또는 knex 모두 지원
|
||||
if (typeof db === 'function') {
|
||||
// Knex
|
||||
existing = await db('users')
|
||||
.where('username', username)
|
||||
.first();
|
||||
} else {
|
||||
// mysql2 pool
|
||||
const [rows] = await db.query('SELECT username FROM users WHERE username = ?', [username]);
|
||||
existing = rows[0];
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
break; // 중복 없음
|
||||
}
|
||||
|
||||
// 중복 시 숫자 추가
|
||||
username = `${baseUsername}${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hangulToRoman,
|
||||
convertToRoman,
|
||||
generateUniqueUsername,
|
||||
decomposeHangul
|
||||
};
|
||||
197
deploy/tkfb-package/api.hyungi.net/utils/logger.js
Normal file
197
deploy/tkfb-package/api.hyungi.net/utils/logger.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 로깅 유틸리티
|
||||
*
|
||||
* 애플리케이션 전체에서 사용하는 통합 로거
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* 로그 레벨 정의
|
||||
*/
|
||||
const LogLevel = {
|
||||
ERROR: 'ERROR',
|
||||
WARN: 'WARN',
|
||||
INFO: 'INFO',
|
||||
DEBUG: 'DEBUG'
|
||||
};
|
||||
|
||||
/**
|
||||
* 로그 레벨별 이모지
|
||||
*/
|
||||
const LogEmoji = {
|
||||
ERROR: '❌',
|
||||
WARN: '⚠️',
|
||||
INFO: 'ℹ️',
|
||||
DEBUG: '🔍'
|
||||
};
|
||||
|
||||
/**
|
||||
* 로그 레벨별 색상 (콘솔)
|
||||
*/
|
||||
const LogColor = {
|
||||
ERROR: '\x1b[31m', // Red
|
||||
WARN: '\x1b[33m', // Yellow
|
||||
INFO: '\x1b[36m', // Cyan
|
||||
DEBUG: '\x1b[90m', // Gray
|
||||
RESET: '\x1b[0m'
|
||||
};
|
||||
|
||||
class Logger {
|
||||
constructor() {
|
||||
this.logDir = path.join(__dirname, '../logs');
|
||||
this.logFile = path.join(this.logDir, 'app.log');
|
||||
this.errorFile = path.join(this.logDir, 'error.log');
|
||||
this.ensureLogDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 디렉토리 생성
|
||||
*/
|
||||
ensureLogDirectory() {
|
||||
if (!fs.existsSync(this.logDir)) {
|
||||
fs.mkdirSync(this.logDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임스탬프 생성
|
||||
*/
|
||||
getTimestamp() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 포맷팅
|
||||
*/
|
||||
formatLog(level, message, context = {}) {
|
||||
const timestamp = this.getTimestamp();
|
||||
const emoji = LogEmoji[level] || '';
|
||||
const contextStr = Object.keys(context).length > 0
|
||||
? `\n Context: ${JSON.stringify(context, null, 2)}`
|
||||
: '';
|
||||
|
||||
return `[${timestamp}] [${level}] ${emoji} ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘솔에 컬러 로그 출력
|
||||
*/
|
||||
logToConsole(level, message, context = {}) {
|
||||
const color = LogColor[level] || LogColor.RESET;
|
||||
const formattedLog = this.formatLog(level, message, context);
|
||||
|
||||
if (level === LogLevel.ERROR) {
|
||||
console.error(`${color}${formattedLog}${LogColor.RESET}`);
|
||||
} else if (level === LogLevel.WARN) {
|
||||
console.warn(`${color}${formattedLog}${LogColor.RESET}`);
|
||||
} else {
|
||||
console.log(`${color}${formattedLog}${LogColor.RESET}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일에 로그 기록
|
||||
*/
|
||||
logToFile(level, message, context = {}) {
|
||||
const formattedLog = this.formatLog(level, message, context);
|
||||
const logEntry = `${formattedLog}\n`;
|
||||
|
||||
try {
|
||||
// 모든 로그를 app.log에 기록
|
||||
fs.appendFileSync(this.logFile, logEntry, 'utf8');
|
||||
|
||||
// 에러는 error.log에도 기록
|
||||
if (level === LogLevel.ERROR) {
|
||||
fs.appendFileSync(this.errorFile, logEntry, 'utf8');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('로그 파일 기록 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 기록 메인 함수
|
||||
*/
|
||||
log(level, message, context = {}) {
|
||||
// 개발 환경에서는 콘솔에 출력
|
||||
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV !== 'production') {
|
||||
this.logToConsole(level, message, context);
|
||||
}
|
||||
|
||||
// 프로덕션에서는 파일에만 기록
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
this.logToFile(level, message, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 로그
|
||||
*/
|
||||
error(message, context = {}) {
|
||||
this.log(LogLevel.ERROR, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 경고 로그
|
||||
*/
|
||||
warn(message, context = {}) {
|
||||
this.log(LogLevel.WARN, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정보 로그
|
||||
*/
|
||||
info(message, context = {}) {
|
||||
this.log(LogLevel.INFO, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 디버그 로그
|
||||
*/
|
||||
debug(message, context = {}) {
|
||||
// DEBUG 로그는 개발 환경에서만 출력
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
this.log(LogLevel.DEBUG, message, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 요청 로그
|
||||
*/
|
||||
http(method, url, statusCode, duration, user = 'anonymous') {
|
||||
const level = statusCode >= 400 ? LogLevel.ERROR : LogLevel.INFO;
|
||||
const message = `${method} ${url} - ${statusCode} (${duration}ms)`;
|
||||
const context = {
|
||||
method,
|
||||
url,
|
||||
statusCode,
|
||||
duration,
|
||||
user
|
||||
};
|
||||
|
||||
this.log(level, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 쿼리 로그
|
||||
*/
|
||||
query(sql, params = [], duration = 0) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
this.debug('DB Query', {
|
||||
sql,
|
||||
params,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 내보내기
|
||||
const logger = new Logger();
|
||||
|
||||
module.exports = logger;
|
||||
173
deploy/tkfb-package/api.hyungi.net/utils/passwordValidator.js
Normal file
173
deploy/tkfb-package/api.hyungi.net/utils/passwordValidator.js
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Password Validator - 비밀번호 정책 검증
|
||||
*
|
||||
* 강력한 비밀번호 정책:
|
||||
* - 최소 12자 이상
|
||||
* - 대문자 포함
|
||||
* - 소문자 포함
|
||||
* - 숫자 포함
|
||||
* - 특수문자 포함
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2026-02-04
|
||||
*/
|
||||
|
||||
/**
|
||||
* 비밀번호 강도 검증
|
||||
*
|
||||
* @param {string} password - 검증할 비밀번호
|
||||
* @param {Object} options - 옵션 (기본값 사용 권장)
|
||||
* @returns {Object} { valid: boolean, errors: string[], strength: string }
|
||||
*/
|
||||
const validatePassword = (password, options = {}) => {
|
||||
const config = {
|
||||
minLength: options.minLength || 12,
|
||||
requireUppercase: options.requireUppercase !== false,
|
||||
requireLowercase: options.requireLowercase !== false,
|
||||
requireNumbers: options.requireNumbers !== false,
|
||||
requireSpecialChars: options.requireSpecialChars !== false,
|
||||
maxLength: options.maxLength || 128
|
||||
};
|
||||
|
||||
const errors = [];
|
||||
let strength = 0;
|
||||
|
||||
// 필수 검증
|
||||
if (!password || typeof password !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ['비밀번호를 입력해주세요.'],
|
||||
strength: 'invalid'
|
||||
};
|
||||
}
|
||||
|
||||
// 길이 검증
|
||||
if (password.length < config.minLength) {
|
||||
errors.push(`비밀번호는 최소 ${config.minLength}자 이상이어야 합니다.`);
|
||||
} else {
|
||||
strength += 1;
|
||||
}
|
||||
|
||||
if (password.length > config.maxLength) {
|
||||
errors.push(`비밀번호는 ${config.maxLength}자를 초과할 수 없습니다.`);
|
||||
}
|
||||
|
||||
// 대문자 검증
|
||||
if (config.requireUppercase && !/[A-Z]/.test(password)) {
|
||||
errors.push('대문자를 1개 이상 포함해야 합니다.');
|
||||
} else if (/[A-Z]/.test(password)) {
|
||||
strength += 1;
|
||||
}
|
||||
|
||||
// 소문자 검증
|
||||
if (config.requireLowercase && !/[a-z]/.test(password)) {
|
||||
errors.push('소문자를 1개 이상 포함해야 합니다.');
|
||||
} else if (/[a-z]/.test(password)) {
|
||||
strength += 1;
|
||||
}
|
||||
|
||||
// 숫자 검증
|
||||
if (config.requireNumbers && !/\d/.test(password)) {
|
||||
errors.push('숫자를 1개 이상 포함해야 합니다.');
|
||||
} else if (/\d/.test(password)) {
|
||||
strength += 1;
|
||||
}
|
||||
|
||||
// 특수문자 검증
|
||||
const specialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/;
|
||||
if (config.requireSpecialChars && !specialChars.test(password)) {
|
||||
errors.push('특수문자를 1개 이상 포함해야 합니다. (!@#$%^&*()_+-=[]{};\':"|,.<>/?)');
|
||||
} else if (specialChars.test(password)) {
|
||||
strength += 1;
|
||||
}
|
||||
|
||||
// 공백 검증
|
||||
if (/\s/.test(password)) {
|
||||
errors.push('비밀번호에 공백을 포함할 수 없습니다.');
|
||||
}
|
||||
|
||||
// 연속된 문자 검증 (선택적)
|
||||
if (/(.)\1{2,}/.test(password)) {
|
||||
errors.push('동일한 문자를 3회 이상 연속 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
// 강도 계산
|
||||
let strengthLabel;
|
||||
if (strength <= 2) {
|
||||
strengthLabel = 'weak';
|
||||
} else if (strength <= 3) {
|
||||
strengthLabel = 'medium';
|
||||
} else if (strength <= 4) {
|
||||
strengthLabel = 'strong';
|
||||
} else {
|
||||
strengthLabel = 'very_strong';
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
strength: strengthLabel,
|
||||
score: strength
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 간단한 비밀번호 검증 (기존 호환용)
|
||||
* 모든 조건을 만족하면 true, 아니면 false
|
||||
*
|
||||
* @param {string} password - 검증할 비밀번호
|
||||
* @returns {boolean} 유효 여부
|
||||
*/
|
||||
const isValidPassword = (password) => {
|
||||
return validatePassword(password).valid;
|
||||
};
|
||||
|
||||
/**
|
||||
* 비밀번호 검증 결과를 한국어 메시지로 반환
|
||||
*
|
||||
* @param {string} password - 검증할 비밀번호
|
||||
* @returns {string|null} 오류 메시지 (유효하면 null)
|
||||
*/
|
||||
const getPasswordError = (password) => {
|
||||
const result = validatePassword(password);
|
||||
if (result.valid) {
|
||||
return null;
|
||||
}
|
||||
return result.errors.join(' ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Express 미들웨어: 요청 body의 password 또는 newPassword 필드 검증
|
||||
*
|
||||
* @param {string} fieldName - 검증할 필드명 (기본: 'password')
|
||||
* @returns {Function} Express 미들웨어
|
||||
*/
|
||||
const validatePasswordMiddleware = (fieldName = 'password') => {
|
||||
return (req, res, next) => {
|
||||
const password = req.body[fieldName] || req.body.newPassword;
|
||||
|
||||
if (!password) {
|
||||
return next(); // 비밀번호 필드가 없으면 다음 미들웨어로
|
||||
}
|
||||
|
||||
const result = validatePassword(password);
|
||||
|
||||
if (!result.valid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '비밀번호가 보안 요구사항을 충족하지 않습니다.',
|
||||
details: result.errors,
|
||||
code: 'WEAK_PASSWORD'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
validatePassword,
|
||||
isValidPassword,
|
||||
getPasswordError,
|
||||
validatePasswordMiddleware
|
||||
};
|
||||
433
deploy/tkfb-package/api.hyungi.net/utils/queryOptimizer.js
Normal file
433
deploy/tkfb-package/api.hyungi.net/utils/queryOptimizer.js
Normal file
@@ -0,0 +1,433 @@
|
||||
// utils/queryOptimizer.js - 데이터베이스 쿼리 최적화 유틸리티
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
/**
|
||||
* SQL Injection 방지를 위한 화이트리스트 검증
|
||||
*/
|
||||
const ALLOWED_ORDER_DIRECTIONS = ['ASC', 'DESC'];
|
||||
const ALLOWED_TABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
const ALLOWED_COLUMN_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
|
||||
|
||||
const validateOrderDirection = (direction) => {
|
||||
const normalized = (direction || 'DESC').toUpperCase();
|
||||
if (!ALLOWED_ORDER_DIRECTIONS.includes(normalized)) {
|
||||
throw new Error(`Invalid order direction: ${direction}`);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const validateIdentifier = (identifier, type = 'column') => {
|
||||
if (!identifier || typeof identifier !== 'string') {
|
||||
throw new Error(`Invalid ${type} name`);
|
||||
}
|
||||
if (!ALLOWED_COLUMN_NAME_PATTERN.test(identifier)) {
|
||||
throw new Error(`Invalid ${type} name: ${identifier}`);
|
||||
}
|
||||
return identifier;
|
||||
};
|
||||
|
||||
const validateTableName = (tableName) => {
|
||||
if (!tableName || typeof tableName !== 'string') {
|
||||
throw new Error('Invalid table name');
|
||||
}
|
||||
if (!ALLOWED_TABLE_NAME_PATTERN.test(tableName)) {
|
||||
throw new Error(`Invalid table name: ${tableName}`);
|
||||
}
|
||||
return tableName;
|
||||
};
|
||||
|
||||
/**
|
||||
* 페이지네이션 헬퍼
|
||||
*/
|
||||
const paginate = (page = 1, limit = 10) => {
|
||||
const pageNum = Math.max(1, parseInt(page));
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(limit))); // 최대 100개 제한
|
||||
const offset = (pageNum - 1) * limitNum;
|
||||
|
||||
return {
|
||||
limit: limitNum,
|
||||
offset,
|
||||
page: pageNum
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 페이지네이션된 쿼리 실행
|
||||
*/
|
||||
const executePagedQuery = async (baseQuery, countQuery, params = [], options = {}) => {
|
||||
const { page = 1, limit = 10, orderBy = 'id', orderDirection = 'DESC' } = options;
|
||||
const { limit: limitNum, offset, page: pageNum } = paginate(page, limit);
|
||||
|
||||
// SQL Injection 방지: 컬럼명과 정렬방향 검증
|
||||
const safeOrderBy = validateIdentifier(orderBy, 'column');
|
||||
const safeOrderDirection = validateOrderDirection(orderDirection);
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 전체 개수 조회
|
||||
const [countResult] = await db.execute(countQuery, params);
|
||||
const totalCount = countResult[0]?.total || 0;
|
||||
|
||||
// 데이터 조회 (ORDER BY와 LIMIT 추가) - 검증된 값만 사용
|
||||
const pagedQuery = `${baseQuery} ORDER BY ${safeOrderBy} ${safeOrderDirection} LIMIT ${limitNum} OFFSET ${offset}`;
|
||||
const [rows] = await db.execute(pagedQuery, params);
|
||||
|
||||
// 페이지네이션 메타데이터 계산
|
||||
const totalPages = Math.ceil(totalCount / limitNum);
|
||||
|
||||
return {
|
||||
data: rows,
|
||||
pagination: {
|
||||
currentPage: pageNum,
|
||||
totalPages,
|
||||
totalCount,
|
||||
limit: limitNum,
|
||||
hasNextPage: pageNum < totalPages,
|
||||
hasPrevPage: pageNum > 1
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`페이지네이션 쿼리 실행 오류: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 인덱스 최적화 제안
|
||||
*/
|
||||
const suggestIndexes = async (tableName) => {
|
||||
// SQL Injection 방지: 테이블명 검증
|
||||
const safeTableName = validateTableName(tableName);
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 현재 인덱스 조회 - 검증된 테이블명 사용
|
||||
const [indexes] = await db.execute(`SHOW INDEX FROM \`${safeTableName}\``);
|
||||
|
||||
// 테이블 구조 조회 - 검증된 테이블명 사용
|
||||
const [columns] = await db.execute(`DESCRIBE \`${safeTableName}\``);
|
||||
|
||||
const suggestions = [];
|
||||
|
||||
// 외래키 컬럼에 인덱스 제안
|
||||
const foreignKeyColumns = columns.filter(col =>
|
||||
col.Field.endsWith('_id') && !indexes.some(idx => idx.Column_name === col.Field)
|
||||
);
|
||||
|
||||
foreignKeyColumns.forEach(col => {
|
||||
suggestions.push({
|
||||
type: 'INDEX',
|
||||
column: col.Field,
|
||||
reason: '외래키 컬럼에 인덱스 추가로 JOIN 성능 향상',
|
||||
sql: `CREATE INDEX idx_${safeTableName}_${col.Field} ON \`${safeTableName}\`(\`${col.Field}\`);`
|
||||
});
|
||||
});
|
||||
|
||||
// 날짜 컬럼에 인덱스 제안
|
||||
const dateColumns = columns.filter(col =>
|
||||
(col.Type.includes('date') || col.Type.includes('timestamp')) &&
|
||||
!indexes.some(idx => idx.Column_name === col.Field)
|
||||
);
|
||||
|
||||
dateColumns.forEach(col => {
|
||||
suggestions.push({
|
||||
type: 'INDEX',
|
||||
column: col.Field,
|
||||
reason: '날짜 범위 검색 성능 향상',
|
||||
sql: `CREATE INDEX idx_${safeTableName}_${col.Field} ON \`${safeTableName}\`(\`${col.Field}\`);`
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
tableName: safeTableName,
|
||||
currentIndexes: indexes.map(idx => ({
|
||||
name: idx.Key_name,
|
||||
column: idx.Column_name,
|
||||
unique: idx.Non_unique === 0
|
||||
})),
|
||||
suggestions
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`인덱스 분석 오류: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 쿼리 성능 분석
|
||||
*/
|
||||
const analyzeQuery = async (query, params = []) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// EXPLAIN 실행
|
||||
const explainQuery = `EXPLAIN ${query}`;
|
||||
const [explainResult] = await db.execute(explainQuery, params);
|
||||
|
||||
// 쿼리 실행 시간 측정
|
||||
const startTime = Date.now();
|
||||
await db.execute(query, params);
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
// 성능 분석
|
||||
const analysis = {
|
||||
executionTime,
|
||||
explainResult,
|
||||
recommendations: []
|
||||
};
|
||||
|
||||
// 성능 권장사항 생성
|
||||
explainResult.forEach(row => {
|
||||
if (row.type === 'ALL') {
|
||||
analysis.recommendations.push({
|
||||
type: 'WARNING',
|
||||
message: `테이블 전체 스캔 발생: ${row.table}`,
|
||||
suggestion: '적절한 인덱스 추가 권장'
|
||||
});
|
||||
}
|
||||
|
||||
if (row.rows > 1000) {
|
||||
analysis.recommendations.push({
|
||||
type: 'WARNING',
|
||||
message: `많은 행 검사: ${row.rows}행`,
|
||||
suggestion: 'WHERE 조건 최적화 또는 인덱스 추가 권장'
|
||||
});
|
||||
}
|
||||
|
||||
if (row.Extra && row.Extra.includes('Using filesort')) {
|
||||
analysis.recommendations.push({
|
||||
type: 'INFO',
|
||||
message: '파일 정렬 사용 중',
|
||||
suggestion: 'ORDER BY 컬럼에 인덱스 추가 고려'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return analysis;
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`쿼리 분석 오류: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 배치 삽입 최적화
|
||||
*/
|
||||
const batchInsert = async (tableName, data, batchSize = 100) => {
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new Error('삽입할 데이터가 없습니다.');
|
||||
}
|
||||
|
||||
// SQL Injection 방지: 테이블명 검증
|
||||
const safeTableName = validateTableName(tableName);
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
const connection = await db.getConnection();
|
||||
|
||||
await connection.beginTransaction();
|
||||
|
||||
const columns = Object.keys(data[0]);
|
||||
// 컬럼명도 검증
|
||||
const safeColumns = columns.map(col => validateIdentifier(col, 'column'));
|
||||
const placeholders = safeColumns.map(() => '?').join(', ');
|
||||
const columnList = safeColumns.map(col => `\`${col}\``).join(', ');
|
||||
const insertQuery = `INSERT INTO \`${safeTableName}\` (${columnList}) VALUES (${placeholders})`;
|
||||
|
||||
let insertedCount = 0;
|
||||
|
||||
// 배치 단위로 처리
|
||||
for (let i = 0; i < data.length; i += batchSize) {
|
||||
const batch = data.slice(i, i + batchSize);
|
||||
|
||||
for (const row of batch) {
|
||||
const values = columns.map(col => row[col]);
|
||||
await connection.execute(insertQuery, values);
|
||||
insertedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
connection.release();
|
||||
|
||||
return {
|
||||
insertedCount,
|
||||
batchSize,
|
||||
totalBatches: Math.ceil(data.length / batchSize)
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`배치 삽입 오류: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 쿼리 캐시 키 생성
|
||||
*/
|
||||
const generateCacheKey = (query, params = [], prefix = 'query') => {
|
||||
const paramString = params.length > 0 ? JSON.stringify(params) : '';
|
||||
const queryHash = require('crypto')
|
||||
.createHash('md5')
|
||||
.update(query + paramString)
|
||||
.digest('hex');
|
||||
|
||||
return `${prefix}:${queryHash}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 자주 사용되는 최적화된 쿼리들
|
||||
*/
|
||||
const optimizedQueries = {
|
||||
// 작업자 목록 (페이지네이션)
|
||||
getWorkersPaged: async (page = 1, limit = 10, search = '', status = '', departmentId = null) => {
|
||||
let baseQuery = `
|
||||
SELECT w.*, d.department_name, COUNT(dwr.id) as report_count
|
||||
FROM workers w
|
||||
LEFT JOIN daily_work_reports dwr ON w.worker_id = dwr.worker_id
|
||||
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||
`;
|
||||
|
||||
let countQuery = `
|
||||
SELECT COUNT(*) as total FROM workers w
|
||||
LEFT JOIN departments d ON w.department_id = d.department_id
|
||||
`;
|
||||
let params = [];
|
||||
let conditions = [];
|
||||
|
||||
// 검색 조건
|
||||
if (search) {
|
||||
conditions.push('(w.worker_name LIKE ? OR w.position LIKE ?)');
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
// 상태 조건
|
||||
if (status) {
|
||||
conditions.push('w.status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
// 부서 조건
|
||||
if (departmentId) {
|
||||
conditions.push('w.department_id = ?');
|
||||
params.push(departmentId);
|
||||
}
|
||||
|
||||
// 조건 조합
|
||||
if (conditions.length > 0) {
|
||||
const whereClause = ' WHERE ' + conditions.join(' AND ');
|
||||
baseQuery += whereClause;
|
||||
countQuery += whereClause;
|
||||
}
|
||||
|
||||
baseQuery += ' GROUP BY w.worker_id';
|
||||
|
||||
return executePagedQuery(baseQuery, countQuery, params, {
|
||||
page, limit, orderBy: 'w.worker_id', orderDirection: 'DESC'
|
||||
});
|
||||
},
|
||||
|
||||
// 프로젝트 목록 (페이지네이션)
|
||||
getProjectsPaged: async (page = 1, limit = 10, status = '') => {
|
||||
let baseQuery = `
|
||||
SELECT p.*, COUNT(dwr.id) as report_count,
|
||||
SUM(dwr.work_hours) as total_hours
|
||||
FROM projects p
|
||||
LEFT JOIN daily_work_reports dwr ON p.project_id = dwr.project_id
|
||||
`;
|
||||
|
||||
let countQuery = 'SELECT COUNT(*) as total FROM projects p';
|
||||
let params = [];
|
||||
|
||||
if (status) {
|
||||
const statusCondition = ' WHERE p.status = ?';
|
||||
baseQuery += statusCondition + ' GROUP BY p.project_id';
|
||||
countQuery += statusCondition;
|
||||
params = [status];
|
||||
} else {
|
||||
baseQuery += ' GROUP BY p.project_id';
|
||||
}
|
||||
|
||||
return executePagedQuery(baseQuery, countQuery, params, {
|
||||
page, limit, orderBy: 'p.project_id', orderDirection: 'DESC'
|
||||
});
|
||||
},
|
||||
|
||||
// 일일 작업 보고서 (날짜 범위, 페이지네이션)
|
||||
getDailyWorkReportsPaged: async (startDate, endDate, page = 1, limit = 10) => {
|
||||
const baseQuery = `
|
||||
SELECT dwr.*, w.worker_name, p.project_name,
|
||||
wt.name as work_type_name, wst.name as work_status_name,
|
||||
et.name as error_type_name, u.name as created_by_name
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
|
||||
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
|
||||
LEFT JOIN error_types et ON dwr.error_type_id = et.id
|
||||
LEFT JOIN users u ON dwr.created_by = u.user_id
|
||||
WHERE dwr.report_date BETWEEN ? AND ?
|
||||
`;
|
||||
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM daily_work_reports dwr
|
||||
WHERE dwr.report_date BETWEEN ? AND ?
|
||||
`;
|
||||
|
||||
return executePagedQuery(baseQuery, countQuery, [startDate, endDate], {
|
||||
page, limit, orderBy: 'dwr.report_date', orderDirection: 'DESC'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 데이터베이스 성능 모니터링
|
||||
*/
|
||||
const getPerformanceStats = async () => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 연결 상태 조회
|
||||
const [connections] = await db.execute('SHOW STATUS LIKE "Threads_connected"');
|
||||
const [maxConnections] = await db.execute('SHOW VARIABLES LIKE "max_connections"');
|
||||
|
||||
// 쿼리 캐시 상태 (MySQL 8.0 이전 버전)
|
||||
let queryCacheStats = null;
|
||||
try {
|
||||
const [qcStats] = await db.execute('SHOW STATUS LIKE "Qcache%"');
|
||||
queryCacheStats = qcStats;
|
||||
} catch (error) {
|
||||
// MySQL 8.0+에서는 쿼리 캐시가 제거됨
|
||||
}
|
||||
|
||||
// 슬로우 쿼리 로그 상태
|
||||
const [slowQueries] = await db.execute('SHOW STATUS LIKE "Slow_queries"');
|
||||
|
||||
return {
|
||||
connections: {
|
||||
current: parseInt(connections[0]?.Value || 0),
|
||||
max: parseInt(maxConnections[0]?.Value || 0)
|
||||
},
|
||||
queryCacheStats,
|
||||
slowQueries: parseInt(slowQueries[0]?.Value || 0),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`성능 통계 조회 오류: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
paginate,
|
||||
executePagedQuery,
|
||||
suggestIndexes,
|
||||
analyzeQuery,
|
||||
batchInsert,
|
||||
generateCacheKey,
|
||||
optimizedQueries,
|
||||
getPerformanceStats
|
||||
};
|
||||
188
deploy/tkfb-package/api.hyungi.net/utils/responseFormatter.js
Normal file
188
deploy/tkfb-package/api.hyungi.net/utils/responseFormatter.js
Normal file
@@ -0,0 +1,188 @@
|
||||
// utils/responseFormatter.js - 통합 응답 포맷터
|
||||
|
||||
/**
|
||||
* 성공 응답 포맷터
|
||||
*/
|
||||
const successResponse = (data = null, message = '요청이 성공적으로 처리되었습니다.', meta = null) => {
|
||||
const response = {
|
||||
success: true,
|
||||
message,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (data !== null) {
|
||||
response.data = data;
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
response.meta = meta;
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* 페이지네이션 응답 포맷터
|
||||
*/
|
||||
const paginatedResponse = (data, totalCount, page = 1, limit = 10, message = '데이터 조회 성공') => {
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
|
||||
return successResponse(data, message, {
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages,
|
||||
totalCount: parseInt(totalCount),
|
||||
limit: parseInt(limit),
|
||||
hasNextPage: page < totalPages,
|
||||
hasPrevPage: page > 1
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 리스트 응답 포맷터
|
||||
*/
|
||||
const listResponse = (items, message = '목록 조회 성공') => {
|
||||
return successResponse(items, message, {
|
||||
count: items.length
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 생성 응답 포맷터
|
||||
*/
|
||||
const createdResponse = (data, message = '데이터가 성공적으로 생성되었습니다.') => {
|
||||
return successResponse(data, message);
|
||||
};
|
||||
|
||||
/**
|
||||
* 업데이트 응답 포맷터
|
||||
*/
|
||||
const updatedResponse = (data = null, message = '데이터가 성공적으로 업데이트되었습니다.') => {
|
||||
return successResponse(data, message);
|
||||
};
|
||||
|
||||
/**
|
||||
* 삭제 응답 포맷터
|
||||
*/
|
||||
const deletedResponse = (message = '데이터가 성공적으로 삭제되었습니다.') => {
|
||||
return successResponse(null, message);
|
||||
};
|
||||
|
||||
/**
|
||||
* 통계 응답 포맷터
|
||||
*/
|
||||
const statsResponse = (stats, period = null, message = '통계 조회 성공') => {
|
||||
const meta = {};
|
||||
if (period) {
|
||||
meta.period = period;
|
||||
}
|
||||
meta.generatedAt = new Date().toISOString();
|
||||
|
||||
return successResponse(stats, message, meta);
|
||||
};
|
||||
|
||||
/**
|
||||
* 인증 응답 포맷터
|
||||
*/
|
||||
const authResponse = (user, token, redirectUrl = null, message = '로그인 성공') => {
|
||||
const data = {
|
||||
user,
|
||||
token
|
||||
};
|
||||
|
||||
if (redirectUrl) {
|
||||
data.redirectUrl = redirectUrl;
|
||||
}
|
||||
|
||||
return successResponse(data, message);
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 업로드 응답 포맷터
|
||||
*/
|
||||
const uploadResponse = (fileInfo, message = '파일 업로드 성공') => {
|
||||
return successResponse({
|
||||
filename: fileInfo.filename,
|
||||
originalName: fileInfo.originalname,
|
||||
size: fileInfo.size,
|
||||
mimetype: fileInfo.mimetype,
|
||||
path: fileInfo.path,
|
||||
uploadedAt: new Date().toISOString()
|
||||
}, message);
|
||||
};
|
||||
|
||||
/**
|
||||
* 헬스체크 응답 포맷터
|
||||
*/
|
||||
const healthResponse = (status = 'healthy', services = {}) => {
|
||||
return successResponse({
|
||||
status,
|
||||
services,
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
version: process.version
|
||||
}, `서버 상태: ${status}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Express 응답 확장 미들웨어
|
||||
*/
|
||||
const responseMiddleware = (req, res, next) => {
|
||||
// 성공 응답 헬퍼들을 res 객체에 추가
|
||||
res.success = (data, message, meta) => {
|
||||
return res.json(successResponse(data, message, meta));
|
||||
};
|
||||
|
||||
res.paginated = (data, totalCount, page, limit, message) => {
|
||||
return res.json(paginatedResponse(data, totalCount, page, limit, message));
|
||||
};
|
||||
|
||||
res.list = (items, message) => {
|
||||
return res.json(listResponse(items, message));
|
||||
};
|
||||
|
||||
res.created = (data, message) => {
|
||||
return res.status(201).json(createdResponse(data, message));
|
||||
};
|
||||
|
||||
res.updated = (data, message) => {
|
||||
return res.json(updatedResponse(data, message));
|
||||
};
|
||||
|
||||
res.deleted = (message) => {
|
||||
return res.json(deletedResponse(message));
|
||||
};
|
||||
|
||||
res.stats = (stats, period, message) => {
|
||||
return res.json(statsResponse(stats, period, message));
|
||||
};
|
||||
|
||||
res.auth = (user, token, redirectUrl, message) => {
|
||||
return res.json(authResponse(user, token, redirectUrl, message));
|
||||
};
|
||||
|
||||
res.upload = (fileInfo, message) => {
|
||||
return res.json(uploadResponse(fileInfo, message));
|
||||
};
|
||||
|
||||
res.health = (status, services) => {
|
||||
return res.json(healthResponse(status, services));
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
successResponse,
|
||||
paginatedResponse,
|
||||
listResponse,
|
||||
createdResponse,
|
||||
updatedResponse,
|
||||
deletedResponse,
|
||||
statsResponse,
|
||||
authResponse,
|
||||
uploadResponse,
|
||||
healthResponse,
|
||||
responseMiddleware
|
||||
};
|
||||
304
deploy/tkfb-package/api.hyungi.net/utils/validator.js
Normal file
304
deploy/tkfb-package/api.hyungi.net/utils/validator.js
Normal file
@@ -0,0 +1,304 @@
|
||||
// utils/validator.js - 유효성 검사 유틸리티
|
||||
|
||||
const { handleValidationError } = require('./errorHandler');
|
||||
|
||||
/**
|
||||
* 필수 필드 검사
|
||||
*/
|
||||
const required = (value, fieldName) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
handleValidationError(fieldName, value, 'required');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 문자열 길이 검사
|
||||
*/
|
||||
const stringLength = (value, fieldName, min = 0, max = Infinity) => {
|
||||
if (typeof value !== 'string') {
|
||||
handleValidationError(fieldName, value, 'string type');
|
||||
}
|
||||
if (value.length < min || value.length > max) {
|
||||
handleValidationError(fieldName, value, `length between ${min} and ${max}`);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 숫자 범위 검사
|
||||
*/
|
||||
const numberRange = (value, fieldName, min = -Infinity, max = Infinity) => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) {
|
||||
handleValidationError(fieldName, value, 'number type');
|
||||
}
|
||||
if (num < min || num > max) {
|
||||
handleValidationError(fieldName, value, `number between ${min} and ${max}`);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 정수 검사
|
||||
*/
|
||||
const integer = (value, fieldName) => {
|
||||
const num = parseInt(value);
|
||||
if (isNaN(num) || num.toString() !== value.toString()) {
|
||||
handleValidationError(fieldName, value, 'integer');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 이메일 형식 검사
|
||||
*/
|
||||
const email = (value, fieldName) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
handleValidationError(fieldName, value, 'valid email format');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 날짜 형식 검사 (YYYY-MM-DD)
|
||||
*/
|
||||
const dateFormat = (value, fieldName) => {
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(value)) {
|
||||
handleValidationError(fieldName, value, 'YYYY-MM-DD format');
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) {
|
||||
handleValidationError(fieldName, value, 'valid date');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 시간 형식 검사 (HH:MM)
|
||||
*/
|
||||
const timeFormat = (value, fieldName) => {
|
||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||
if (!timeRegex.test(value)) {
|
||||
handleValidationError(fieldName, value, 'HH:MM format');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 열거형 값 검사
|
||||
*/
|
||||
const enumValue = (value, fieldName, allowedValues) => {
|
||||
if (!allowedValues.includes(value)) {
|
||||
handleValidationError(fieldName, value, `one of: ${allowedValues.join(', ')}`);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 배열 검사
|
||||
*/
|
||||
const arrayType = (value, fieldName, minLength = 0, maxLength = Infinity) => {
|
||||
if (!Array.isArray(value)) {
|
||||
handleValidationError(fieldName, value, 'array type');
|
||||
}
|
||||
if (value.length < minLength || value.length > maxLength) {
|
||||
handleValidationError(fieldName, value, `array length between ${minLength} and ${maxLength}`);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 객체 검사
|
||||
*/
|
||||
const objectType = (value, fieldName) => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
handleValidationError(fieldName, value, 'object type');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 비밀번호 강도 검사
|
||||
*/
|
||||
const passwordStrength = (value, fieldName) => {
|
||||
if (typeof value !== 'string') {
|
||||
handleValidationError(fieldName, value, 'string type');
|
||||
}
|
||||
|
||||
const minLength = 8;
|
||||
const hasUpperCase = /[A-Z]/.test(value);
|
||||
const hasLowerCase = /[a-z]/.test(value);
|
||||
const hasNumbers = /\d/.test(value);
|
||||
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(value);
|
||||
|
||||
if (value.length < minLength) {
|
||||
handleValidationError(fieldName, value, `minimum ${minLength} characters`);
|
||||
}
|
||||
|
||||
const strengthChecks = [hasUpperCase, hasLowerCase, hasNumbers, hasSpecialChar];
|
||||
const passedChecks = strengthChecks.filter(Boolean).length;
|
||||
|
||||
if (passedChecks < 3) {
|
||||
handleValidationError(fieldName, value, 'at least 3 of: uppercase, lowercase, numbers, special characters');
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 스키마 기반 유효성 검사
|
||||
*/
|
||||
const validateSchema = (data, schema) => {
|
||||
const errors = [];
|
||||
|
||||
for (const [field, rules] of Object.entries(schema)) {
|
||||
const value = data[field];
|
||||
|
||||
try {
|
||||
// 필수 필드 검사
|
||||
if (rules.required) {
|
||||
required(value, field);
|
||||
}
|
||||
|
||||
// 값이 없고 필수가 아니면 다른 검사 스킵
|
||||
if ((value === undefined || value === null || value === '') && !rules.required) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 타입별 검사
|
||||
if (rules.type === 'string' && rules.minLength !== undefined && rules.maxLength !== undefined) {
|
||||
stringLength(value, field, rules.minLength, rules.maxLength);
|
||||
}
|
||||
|
||||
if (rules.type === 'number' && rules.min !== undefined && rules.max !== undefined) {
|
||||
numberRange(value, field, rules.min, rules.max);
|
||||
}
|
||||
|
||||
if (rules.type === 'integer') {
|
||||
integer(value, field);
|
||||
}
|
||||
|
||||
if (rules.type === 'email') {
|
||||
email(value, field);
|
||||
}
|
||||
|
||||
if (rules.type === 'date') {
|
||||
dateFormat(value, field);
|
||||
}
|
||||
|
||||
if (rules.type === 'time') {
|
||||
timeFormat(value, field);
|
||||
}
|
||||
|
||||
if (rules.type === 'array') {
|
||||
arrayType(value, field, rules.minLength, rules.maxLength);
|
||||
}
|
||||
|
||||
if (rules.type === 'object') {
|
||||
objectType(value, field);
|
||||
}
|
||||
|
||||
if (rules.enum) {
|
||||
enumValue(value, field, rules.enum);
|
||||
}
|
||||
|
||||
if (rules.password) {
|
||||
passwordStrength(value, field);
|
||||
}
|
||||
|
||||
// 커스텀 검증 함수
|
||||
if (rules.custom && typeof rules.custom === 'function') {
|
||||
rules.custom(value, field);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
field,
|
||||
value,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = errors.map(e => `${e.field}: ${e.message}`).join(', ');
|
||||
handleValidationError('validation', 'multiple fields', errorMessage);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 일반적인 스키마 정의들
|
||||
*/
|
||||
const schemas = {
|
||||
// 사용자 생성
|
||||
createUser: {
|
||||
username: { required: true, type: 'string', minLength: 3, maxLength: 50 },
|
||||
password: { required: true, password: true },
|
||||
name: { required: true, type: 'string', minLength: 2, maxLength: 100 },
|
||||
access_level: { required: true, enum: ['user', 'admin', 'system'] },
|
||||
worker_id: { type: 'integer' }
|
||||
},
|
||||
|
||||
// 사용자 업데이트
|
||||
updateUser: {
|
||||
name: { type: 'string', minLength: 2, maxLength: 100 },
|
||||
access_level: { enum: ['user', 'admin', 'system'] },
|
||||
worker_id: { type: 'integer' }
|
||||
},
|
||||
|
||||
// 비밀번호 변경
|
||||
changePassword: {
|
||||
currentPassword: { required: true, type: 'string' },
|
||||
newPassword: { required: true, password: true }
|
||||
},
|
||||
|
||||
// 일일 작업 보고서 생성 (배열 형태)
|
||||
createDailyWorkReport: {
|
||||
report_date: { required: true, type: 'date' },
|
||||
worker_id: { required: true, type: 'integer' },
|
||||
work_entries: { required: true, type: 'array' },
|
||||
created_by: { type: 'integer' }
|
||||
},
|
||||
|
||||
// 프로젝트 생성
|
||||
createProject: {
|
||||
project_name: { required: true, type: 'string', minLength: 2, maxLength: 200 },
|
||||
description: { type: 'string', maxLength: 1000 },
|
||||
start_date: { type: 'date' },
|
||||
end_date: { type: 'date' }
|
||||
},
|
||||
|
||||
// 작업자 생성
|
||||
createWorker: {
|
||||
worker_name: { required: true, type: 'string', minLength: 2, maxLength: 100 },
|
||||
position: { type: 'string', maxLength: 100 },
|
||||
department: { type: 'string', maxLength: 100 },
|
||||
phone: { type: 'string', maxLength: 20 },
|
||||
email: { type: 'email' }
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// 개별 검증 함수들
|
||||
required,
|
||||
stringLength,
|
||||
numberRange,
|
||||
integer,
|
||||
email,
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
enumValue,
|
||||
arrayType,
|
||||
objectType,
|
||||
passwordStrength,
|
||||
|
||||
// 스키마 검증
|
||||
validateSchema,
|
||||
schemas
|
||||
};
|
||||
Reference in New Issue
Block a user