From 4716434d65cba8ee5eb72a810a44d4e221167abe Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 3 Nov 2025 10:42:29 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20API=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=ED=91=9C?= =?UTF-8?q?=EC=A4=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 통합 에러 처리 시스템 구축: * utils/errorHandler.js: ApiError 클래스 및 에러 미들웨어 * 데이터베이스, 유효성 검사, 권한 에러 표준화 * 비동기 함수 래퍼 (asyncHandler) 추가 - 응답 포맷터 시스템 구축: * utils/responseFormatter.js: 일관된 API 응답 형식 * 성공, 페이지네이션, 인증, 파일업로드 등 전용 포맷터 * Express 응답 확장 미들웨어 - 유효성 검사 시스템 구축: * utils/validator.js: 스키마 기반 유효성 검사 * 필수 필드, 타입, 길이, 형식 검사 함수들 * 일반적인 스키마 정의 (사용자, 프로젝트, 작업보고서 등) - 코드 정리 및 표준화: * 삭제된 테이블 참조 제거 (work_report_audit_log 등) * 대문자 테이블명을 소문자로 통일 (Users -> users) * authController.js에 새로운 유틸리티 적용 예시 - 미들웨어 통합: * index.js에 에러 핸들러 및 응답 포맷터 적용 * 헬스체크 엔드포인트 개선 --- api.hyungi.net/controllers/authController.js | 52 ++- api.hyungi.net/index.js | 17 +- api.hyungi.net/models/dailyWorkReportModel.js | 36 +- api.hyungi.net/routes/authRoutes.js | 6 +- api.hyungi.net/utils/errorHandler.js | 143 ++++++++ api.hyungi.net/utils/responseFormatter.js | 188 +++++++++++ api.hyungi.net/utils/validator.js | 307 ++++++++++++++++++ docker-compose.yml | 76 +++-- 8 files changed, 720 insertions(+), 105 deletions(-) create mode 100644 api.hyungi.net/utils/errorHandler.js create mode 100644 api.hyungi.net/utils/responseFormatter.js create mode 100644 api.hyungi.net/utils/validator.js diff --git a/api.hyungi.net/controllers/authController.js b/api.hyungi.net/controllers/authController.js index a7d2c90..0341059 100644 --- a/api.hyungi.net/controllers/authController.js +++ b/api.hyungi.net/controllers/authController.js @@ -2,38 +2,32 @@ const { getDb } = require('../dbPool'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const authService = require('../services/auth.service'); +const { ApiError, asyncHandler } = require('../utils/errorHandler'); +const { validateSchema, schemas } = require('../utils/validator'); -const login = async (req, res) => { - try { - const { username, password } = req.body; - const ipAddress = req.ip || req.connection.remoteAddress; - const userAgent = req.headers['user-agent']; +const login = asyncHandler(async (req, res) => { + const { username, password } = req.body; + const ipAddress = req.ip || req.connection.remoteAddress; + const userAgent = req.headers['user-agent']; - if (!username || !password) { - return res.status(400).json({ error: '사용자명과 비밀번호를 입력해주세요.' }); - } - - const result = await authService.loginService(username, password, ipAddress, userAgent); - - if (!result.success) { - return res.status(result.status || 400).json({ error: result.error }); - } - - // 로그인 성공 후, 모든 권한을 그룹장 대시보드로 통일 - const user = result.data.user; - let redirectUrl = '/pages/dashboard/group-leader.html'; // 모든 사용자를 그룹장 대시보드로 리다이렉트 - - // 최종 응답에 redirectUrl을 포함하여 전달 - res.json({ - ...result.data, - redirectUrl: redirectUrl - }); - - } catch (error) { - console.error('Login controller error:', error); - res.status(500).json({ error: error.message || '서버 오류가 발생했습니다.' }); + // 유효성 검사 + if (!username || !password) { + throw new ApiError('사용자명과 비밀번호를 입력해주세요.', 400); } -}; + + const result = await authService.loginService(username, password, ipAddress, userAgent); + + if (!result.success) { + throw new ApiError(result.error, result.status || 400); + } + + // 로그인 성공 후, 모든 권한을 그룹장 대시보드로 통일 + const user = result.data.user; + const redirectUrl = '/pages/dashboard/group-leader.html'; // 모든 사용자를 그룹장 대시보드로 리다이렉트 + + // 새로운 응답 포맷터 사용 + res.auth(user, result.data.token, redirectUrl, '로그인 성공'); +}); // ✅ 사용자 등록 기능 추가 exports.register = async (req, res) => { diff --git a/api.hyungi.net/index.js b/api.hyungi.net/index.js index fdd30b1..6fa9e7b 100644 --- a/api.hyungi.net/index.js +++ b/api.hyungi.net/index.js @@ -4,6 +4,11 @@ const cors = require('cors'); const path = require('path'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); + +// 새로운 유틸리티들 import +const { errorMiddleware } = require('./utils/errorHandler'); +const { responseMiddleware } = require('./utils/responseFormatter'); + const app = express(); // 헬스체크와 개발용 엔드포인트는 CORS 이후에 등록 @@ -31,6 +36,9 @@ app.use(helmet({ app.use(express.urlencoded({ extended: true, limit: '50mb' })); app.use(express.json({ limit: '50mb' })); +// ✅ 응답 포맷터 미들웨어 적용 +app.use(responseMiddleware); + //개발용 CORS 설정 (수정됨) app.use(cors({ origin: function (origin, callback) { @@ -92,13 +100,10 @@ app.get('/api/ping', (req, res) => { // ✅ 서버 상태 엔드포인트 app.get('/api/status', (req, res) => { console.log('📊 Status 요청 받음!'); - res.status(200).json({ - status: 'running', + res.health('running', { service: 'Hyungi API', version: '2.1.0', - environment: process.env.NODE_ENV || 'development', - uptime: process.uptime(), - timestamp: new Date().toISOString() + environment: process.env.NODE_ENV || 'development' }); }); @@ -317,6 +322,8 @@ app.use('/api/tools', toolsRoute); // 📤 파일 업로드 app.use('/api', uploadBgRoutes); +// ===== 🚨 에러 핸들러 (모든 라우트 뒤에 위치) ===== +app.use(errorMiddleware); // ===== 🔍 API 정보 엔드포인트 ===== app.get('/api', (req, res) => { diff --git a/api.hyungi.net/models/dailyWorkReportModel.js b/api.hyungi.net/models/dailyWorkReportModel.js index 92553b4..4bb339a 100644 --- a/api.hyungi.net/models/dailyWorkReportModel.js +++ b/api.hyungi.net/models/dailyWorkReportModel.js @@ -268,28 +268,8 @@ const removeSpecificEntry = async (entry_id, deleted_by, callback) => { // 개별 항목 삭제 const [result] = await conn.query('DELETE FROM daily_work_reports WHERE id = ?', [entry_id]); - // 감사 로그 - try { - await conn.query( - `INSERT INTO work_report_audit_log - (action, report_id, old_values, changed_by, change_reason, created_at) - VALUES (?, ?, ?, ?, ?, NOW())`, - [ - 'DELETE_SINGLE', - entry_id, - JSON.stringify({ - worker_name: entry.worker_name, - project_name: entry.project_name, - work_hours: entry.work_hours, - report_date: entry.report_date - }), - deleted_by, - `개별 항목 삭제` - ] - ); - } catch (auditErr) { - console.warn('감사 로그 추가 실패:', auditErr.message); - } + // 감사 로그 (삭제된 테이블이므로 콘솔 로그로 대체) + console.log(`[삭제 로그] 작업자: ${entry.worker_name}, 프로젝트: ${entry.project_name}, 작업시간: ${entry.work_hours}시간, 삭제자: ${deleted_by}`); await conn.commit(); callback(null, { @@ -968,17 +948,9 @@ const removeReportById = async (reportId, deletedByUserId) => { // 실제 삭제 작업 const [result] = await conn.query('DELETE FROM daily_work_reports WHERE report_id = ?', [reportId]); - // 감사 로그 추가 (삭제된 항목이 있고, 삭제자가 명시된 경우) + // 감사 로그 (삭제된 테이블이므로 콘솔 로그로 대체) if (reportInfo.length > 0 && deletedByUserId) { - try { - await conn.query( - `INSERT INTO work_report_audit_log (action, report_id, old_values, changed_by, change_reason) VALUES (?, ?, ?, ?, ?)`, - ['DELETE', reportId, JSON.stringify(reportInfo[0]), deletedByUserId, 'Manual deletion by user'] - ); - } catch (auditErr) { - console.warn('감사 로그 추가 실패:', auditErr.message); - // 감사 로그 실패가 전체 트랜잭션을 롤백시키지는 않음 - } + console.log(`[삭제 로그] 보고서 ID: ${reportId}, 삭제자: ${deletedByUserId}, 사유: Manual deletion by user`); } await conn.commit(); diff --git a/api.hyungi.net/routes/authRoutes.js b/api.hyungi.net/routes/authRoutes.js index 14928ba..afaa9fd 100644 --- a/api.hyungi.net/routes/authRoutes.js +++ b/api.hyungi.net/routes/authRoutes.js @@ -786,7 +786,7 @@ router.delete('/users/:id', verifyToken, async (req, res) => { // 사용자 존재 확인 const [existing] = await connection.execute( - 'SELECT username FROM Users WHERE user_id = ?', + 'SELECT username FROM users WHERE user_id = ?', [userId] ); @@ -799,12 +799,12 @@ router.delete('/users/:id', verifyToken, async (req, res) => { // 소프트 삭제 (실제로는 비활성화) await connection.execute( - 'UPDATE Users SET is_active = FALSE, updated_at = NOW() WHERE user_id = ?', + 'UPDATE users SET is_active = FALSE, updated_at = NOW() WHERE user_id = ?', [userId] ); // 또는 하드 삭제 (실제로 삭제) - // await connection.execute('DELETE FROM Users WHERE user_id = ?', [userId]); + // await connection.execute('DELETE FROM users WHERE user_id = ?', [userId]); console.log(`[사용자 삭제] 대상: ${existing[0].username} - 삭제자: ${req.user.username}`); diff --git a/api.hyungi.net/utils/errorHandler.js b/api.hyungi.net/utils/errorHandler.js new file mode 100644 index 0000000..4cd1a47 --- /dev/null +++ b/api.hyungi.net/utils/errorHandler.js @@ -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 +}; diff --git a/api.hyungi.net/utils/responseFormatter.js b/api.hyungi.net/utils/responseFormatter.js new file mode 100644 index 0000000..46365bc --- /dev/null +++ b/api.hyungi.net/utils/responseFormatter.js @@ -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 +}; diff --git a/api.hyungi.net/utils/validator.js b/api.hyungi.net/utils/validator.js new file mode 100644 index 0000000..a11edb3 --- /dev/null +++ b/api.hyungi.net/utils/validator.js @@ -0,0 +1,307 @@ +// 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' }, + project_id: { required: true, type: 'integer' }, + work_type_id: { required: true, type: 'integer' }, + work_hours: { required: true, type: 'number', min: 0.1, max: 24 }, + work_status_id: { type: 'integer' }, + error_type_id: { 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 +}; diff --git a/docker-compose.yml b/docker-compose.yml index c99c641..87a974d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,96 +4,99 @@ services: # MariaDB 데이터베이스 db: image: mariadb:10.9 - container_name: fb_db + container_name: tkfb_db restart: unless-stopped environment: - - MYSQL_ROOT_PASSWORD=hyungi_root_password_2025 + - MYSQL_ROOT_PASSWORD=tkfb2024! - MYSQL_DATABASE=hyungi - - MYSQL_USER=hyungi - - MYSQL_PASSWORD=hyungi_password_2025 + - MYSQL_USER=hyungi_user + - MYSQL_PASSWORD=hyungi2024! volumes: - db_data:/var/lib/mysql - ./api.hyungi.net/migrations:/docker-entrypoint-initdb.d ports: - "20306:3306" - networks: - - fb_network healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] timeout: 20s retries: 10 + networks: + - tkfb_network - # API 서버 (Node.js) + # Node.js API 서버 api: build: context: ./api.hyungi.net dockerfile: Dockerfile - container_name: fb_api + container_name: tkfb_api depends_on: db: condition: service_healthy restart: unless-stopped ports: - - "20005:20005" + - "20005:3005" environment: - NODE_ENV=production + - PORT=3005 - DB_HOST=db + - DB_PORT=3306 + - DB_USER=hyungi_user + - DB_PASSWORD=hyungi2024! - DB_NAME=hyungi - - DB_USER=hyungi - - DB_PASSWORD=hyungi_password_2025 - - DB_ROOT_PASSWORD=hyungi_root_password_2025 + - DB_ROOT_PASSWORD=tkfb2024! + - JWT_SECRET=tkfb_jwt_secret_2024_hyungi_secure_key volumes: - ./api.hyungi.net/public/img:/usr/src/app/public/img:ro - ./api.hyungi.net/uploads:/usr/src/app/uploads - ./api.hyungi.net/logs:/usr/src/app/logs - networks: - - fb_network + - ./api.hyungi.net/routes:/usr/src/app/routes + - ./api.hyungi.net/controllers:/usr/src/app/controllers + - ./api.hyungi.net/models:/usr/src/app/models + - ./api.hyungi.net/index.js:/usr/src/app/index.js logging: driver: "json-file" options: max-size: "10m" max-file: "3" + networks: + - tkfb_network - # 웹 UI (Nginx) - web-ui: + # Web UI (Nginx) + web: build: context: ./web-ui dockerfile: Dockerfile - container_name: fb_web_ui + container_name: tkfb_web restart: unless-stopped ports: - "20000:80" volumes: - ./web-ui:/usr/share/nginx/html:ro - networks: - - fb_network depends_on: - api + networks: + - tkfb_network - # FastAPI 브릿지 - fastapi-bridge: + # FastAPI Bridge + fastapi: build: context: ./fastapi-bridge dockerfile: Dockerfile - container_name: fb_fastapi_bridge + container_name: tkfb_fastapi restart: unless-stopped ports: - - "20010:8000" + - "20008:8000" environment: - - EXPRESS_API_URL=http://api:20005 - - NODE_ENV=production + - API_BASE_URL=http://api:3005 + depends_on: + - api networks: - - fb_network - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 30s - timeout: 10s - retries: 3 + - tkfb_network - # phpMyAdmin (DB 관리도구) + # phpMyAdmin phpmyadmin: image: phpmyadmin/phpmyadmin:latest - container_name: fb_phpmyadmin + container_name: tkfb_phpmyadmin depends_on: - db restart: unless-stopped @@ -102,15 +105,16 @@ services: environment: - PMA_HOST=db - PMA_USER=root - - PMA_PASSWORD=hyungi_root_password_2025 + - PMA_PASSWORD=tkfb2024! - UPLOAD_LIMIT=50M networks: - - fb_network + - tkfb_network volumes: db_data: driver: local networks: - fb_network: + tkfb_network: driver: bridge + name: tkfb_network