fix: 캘린더 모달 중복 카드 문제 및 삭제 권한 개선
- monthly_worker_status 조회 시 GROUP BY로 중복 데이터 합산 - 작업보고서 삭제 권한을 그룹장 이상으로 제한 (admin, system, group_leader) - 중복 데이터 정리를 위한 마이그레이션 SQL 추가 (009_fix_duplicate_monthly_status.sql) - synology_deployment 버전에도 동일 수정 적용
This commit is contained in:
146
synology_deployment/api/utils/access.js
Normal file
146
synology_deployment/api/utils/access.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// 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';
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// 기본 유틸리티 함수들
|
||||
hasPermission,
|
||||
getLevelNumber,
|
||||
getLevelName,
|
||||
hasAnyPermission,
|
||||
getAllLevels,
|
||||
getLevelsAbove,
|
||||
|
||||
// 프론트엔드용 함수들
|
||||
canAccessPage,
|
||||
canPerformAction,
|
||||
|
||||
// 상수들
|
||||
ACCESS_LEVELS,
|
||||
ACCESS_LEVEL_NAMES
|
||||
};
|
||||
143
synology_deployment/api/utils/errorHandler.js
Normal file
143
synology_deployment/api/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
|
||||
};
|
||||
362
synology_deployment/api/utils/queryOptimizer.js
Normal file
362
synology_deployment/api/utils/queryOptimizer.js
Normal file
@@ -0,0 +1,362 @@
|
||||
// utils/queryOptimizer.js - 데이터베이스 쿼리 최적화 유틸리티
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
/**
|
||||
* 페이지네이션 헬퍼
|
||||
*/
|
||||
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);
|
||||
|
||||
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 ${orderBy} ${orderDirection} 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) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 현재 인덱스 조회
|
||||
const [indexes] = await db.execute(`SHOW INDEX FROM ${tableName}`);
|
||||
|
||||
// 테이블 구조 조회
|
||||
const [columns] = await db.execute(`DESCRIBE ${tableName}`);
|
||||
|
||||
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_${tableName}_${col.Field} ON ${tableName}(${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_${tableName}_${col.Field} ON ${tableName}(${col.Field});`
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
tableName,
|
||||
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('삽입할 데이터가 없습니다.');
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
const connection = await db.getConnection();
|
||||
|
||||
await connection.beginTransaction();
|
||||
|
||||
const columns = Object.keys(data[0]);
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
const insertQuery = `INSERT INTO ${tableName} (${columns.join(', ')}) 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 = '') => {
|
||||
let baseQuery = `
|
||||
SELECT w.*, COUNT(dwr.id) as report_count
|
||||
FROM workers w
|
||||
LEFT JOIN daily_work_reports dwr ON w.worker_id = dwr.worker_id
|
||||
`;
|
||||
|
||||
let countQuery = 'SELECT COUNT(*) as total FROM workers w';
|
||||
let params = [];
|
||||
|
||||
if (search) {
|
||||
const searchCondition = ' WHERE w.worker_name LIKE ? OR w.position LIKE ?';
|
||||
baseQuery += searchCondition + ' GROUP BY w.worker_id';
|
||||
countQuery += searchCondition;
|
||||
params = [`%${search}%`, `%${search}%`];
|
||||
} else {
|
||||
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
synology_deployment/api/utils/responseFormatter.js
Normal file
188
synology_deployment/api/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
synology_deployment/api/utils/validator.js
Normal file
304
synology_deployment/api/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