diff --git a/api.hyungi.net/config/database.js b/api.hyungi.net/config/database.js new file mode 100644 index 0000000..f7200ac --- /dev/null +++ b/api.hyungi.net/config/database.js @@ -0,0 +1,79 @@ +/** + * 데이터베이스 연결 설정 + * + * MySQL/MariaDB 커넥션 풀 관리 + * - 환경 변수 기반 설정 + * - 자동 재연결 (최대 5회 재시도) + * - UTF-8MB4 문자셋 지원 + * + * @author TK-FB-Project + * @since 2025-12-11 + */ + +require('dotenv').config(); +const mysql = require('mysql2/promise'); +const retry = require('async-retry'); +const logger = require('../utils/logger'); + +let pool = null; + +async function initPool() { + if (pool) return pool; + + const { + DB_HOST, DB_PORT, DB_USER, + DB_PASSWORD, DB_NAME, + DB_SOCKET, DB_CONN_LIMIT = '10' + } = process.env; + + if (!DB_USER || !DB_PASSWORD || !DB_NAME) { + throw new Error('필수 환경변수(DB_USER, DB_PASSWORD, DB_NAME)가 없습니다.'); + } + if (!DB_SOCKET && !DB_HOST) { + throw new Error('DB_SOCKET이 없으면 DB_HOST가 반드시 필요합니다.'); + } + + await retry(async () => { + const config = { + user: DB_USER, + password: DB_PASSWORD, + database: DB_NAME, + waitForConnections: true, + connectionLimit: parseInt(DB_CONN_LIMIT, 10), + queueLimit: 0, + charset: 'utf8mb4' + }; + if (DB_SOCKET) { + config.socketPath = DB_SOCKET; + } else { + config.host = DB_HOST; + config.port = parseInt(DB_PORT, 10); + } + + pool = mysql.createPool(config); + + // 첫 연결 검증 + const conn = await pool.getConnection(); + await conn.query('SET NAMES utf8mb4'); + conn.release(); + + const connectionInfo = DB_SOCKET ? `socket=${DB_SOCKET}` : `${DB_HOST}:${DB_PORT}`; + logger.info('MariaDB 연결 성공', { + connection: connectionInfo, + database: DB_NAME, + connectionLimit: parseInt(DB_CONN_LIMIT, 10) + }); + }, { + retries: 5, + factor: 2, + minTimeout: 1000 + }); + + return pool; +} + +async function getDb() { + return initPool(); +} + +module.exports = { getDb }; \ No newline at end of file diff --git a/api.hyungi.net/config/middleware.js b/api.hyungi.net/config/middleware.js new file mode 100644 index 0000000..680be4f --- /dev/null +++ b/api.hyungi.net/config/middleware.js @@ -0,0 +1,65 @@ +/** + * 미들웨어 설정 + * + * Express 애플리케이션의 모든 미들웨어를 등록하는 중앙화된 설정 파일 + * + * @author TK-FB-Project + * @since 2025-12-11 + */ + +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const compression = require('compression'); +const path = require('path'); +const helmetOptions = require('./security'); +const corsOptions = require('./cors'); +const { responseMiddleware } = require('../utils/responseFormatter'); +const logger = require('../utils/logger'); + +/** + * 모든 미들웨어를 Express 앱에 등록 + * @param {Express.Application} app - Express 애플리케이션 인스턴스 + */ +function setupMiddlewares(app) { + // 보안 헤더 설정 (Helmet) + app.use(helmet(helmetOptions)); + + // 성능 최적화 - Compression + app.use(compression({ + filter: (req, res) => { + if (req.headers['x-no-compression']) { + return false; + } + return compression.filter(req, res); + }, + level: 6, // 압축 레벨 (1-9, 6이 기본값) + threshold: 1024 // 1KB 이상만 압축 + })); + + // 요청 바디 파싱 - 용량 제한 확장 + app.use(express.urlencoded({ extended: true, limit: '50mb' })); + app.use(express.json({ limit: '50mb' })); + + // 응답 포맷터 미들웨어 + app.use(responseMiddleware); + + // CORS 설정 + app.use(cors(corsOptions)); + + // 정적 파일 서빙 + app.use(express.static(path.join(__dirname, '../public'))); + app.use('/uploads', express.static(path.join(__dirname, '../uploads'))); + + // Rate Limiting (필요시 활성화) + // const rateLimit = require('express-rate-limit'); + // const limiter = rateLimit({ + // windowMs: 15 * 60 * 1000, // 15분 + // max: 100 // IP당 최대 100 요청 + // }); + // app.use('/api/', limiter); + + logger.info('미들웨어 설정 완료'); +} + +module.exports = setupMiddlewares; diff --git a/api.hyungi.net/config/routes.js b/api.hyungi.net/config/routes.js new file mode 100644 index 0000000..0229776 --- /dev/null +++ b/api.hyungi.net/config/routes.js @@ -0,0 +1,153 @@ +/** + * 라우트 설정 + * + * 애플리케이션의 모든 라우트를 등록하는 중앙화된 설정 파일 + * + * @author TK-FB-Project + * @since 2025-12-11 + */ + +const swaggerUi = require('swagger-ui-express'); +const swaggerSpec = require('./swagger'); +const { verifyToken } = require('../middlewares/authMiddleware'); +const { activityLogger } = require('../middlewares/activityLogger'); +const logger = require('../utils/logger'); + +/** + * 모든 라우트를 Express 앱에 등록 + * @param {Express.Application} app - Express 애플리케이션 인스턴스 + */ +function setupRoutes(app) { + // 라우터 가져오기 + const authRoutes = require('../routes/authRoutes'); + const projectRoutes = require('../routes/projectRoutes'); + const workerRoutes = require('../routes/workerRoutes'); + const workReportRoutes = require('../routes/workReportRoutes'); + const toolsRoute = require('../routes/toolsRoute'); + const uploadRoutes = require('../routes/uploadRoutes'); + const uploadBgRoutes = require('../routes/uploadBgRoutes'); + const dailyIssueReportRoutes = require('../routes/dailyIssueReportRoutes'); + const issueTypeRoutes = require('../routes/issueTypeRoutes'); + const healthRoutes = require('../routes/healthRoutes'); + const dailyWorkReportRoutes = require('../routes/dailyWorkReportRoutes'); + const workAnalysisRoutes = require('../routes/workAnalysisRoutes'); + const analysisRoutes = require('../routes/analysisRoutes'); + const systemRoutes = require('../routes/systemRoutes'); + const performanceRoutes = require('../routes/performanceRoutes'); + const userRoutes = require('../routes/userRoutes'); + const setupRoutes = require('../routes/setupRoutes'); + const workReportAnalysisRoutes = require('../routes/workReportAnalysisRoutes'); + const attendanceRoutes = require('../routes/attendanceRoutes'); + const monthlyStatusRoutes = require('../routes/monthlyStatusRoutes'); + + // Rate Limiters 설정 + const rateLimit = require('express-rate-limit'); + + const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15분 + max: 5, // 최대 5회 + message: '너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.', + standardHeaders: true, + legacyHeaders: false + }); + + const apiLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, // 1분 + max: 100, // 최대 100회 + message: 'API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.', + standardHeaders: true, + legacyHeaders: false + }); + + // 모든 API 요청에 활동 로거 적용 + app.use('/api/*', activityLogger); + + // 인증 불필요 경로 - 로그인 + app.use('/api/auth', loginLimiter, authRoutes); + + // DB 설정 라우트 (개발용) + app.use('/api/setup', setupRoutes); + + // Health check + app.use('/api', healthRoutes); + + // 일반 API에 속도 제한 적용 + app.use('/api/', apiLimiter); + + // 인증이 필요 없는 공개 경로 목록 + const publicPaths = [ + '/api/auth/login', + '/api/auth/refresh-token', + '/api/auth/check-password-strength', + '/api/health', + '/api/ping', + '/api/status', + '/api/setup/setup-attendance-db', + '/api/setup/setup-monthly-status', + '/api/setup/add-overtime-warning', + '/api/setup/migrate-existing-data', + '/api/setup/check-data-status', + '/api/monthly-status/calendar', + '/api/monthly-status/daily-details' + ]; + + // 인증 미들웨어 - 공개 경로를 제외한 모든 API + app.use('/api/*', (req, res, next) => { + const isPublicPath = publicPaths.some(path => { + return req.originalUrl === path || + req.originalUrl.startsWith(path + '?') || + req.originalUrl.startsWith(path + '/'); + }); + + if (isPublicPath) { + logger.debug('공개 경로 허용', { url: req.originalUrl }); + return next(); + } + + logger.debug('인증 필요 경로', { url: req.originalUrl }); + verifyToken(req, res, next); + }); + + // 인증된 사용자만 접근 가능한 라우트들 + app.use('/api/issue-reports', dailyIssueReportRoutes); + app.use('/api/issue-types', issueTypeRoutes); + app.use('/api/workers', workerRoutes); + app.use('/api/daily-work-reports', dailyWorkReportRoutes); + app.use('/api/work-analysis', workAnalysisRoutes); + app.use('/api/analysis', analysisRoutes); + app.use('/api/daily-work-reports-analysis', workReportAnalysisRoutes); + app.use('/api/attendance', attendanceRoutes); + app.use('/api/monthly-status', monthlyStatusRoutes); + app.use('/api/workreports', workReportRoutes); + app.use('/api/system', systemRoutes); + app.use('/api/uploads', uploadRoutes); + app.use('/api/performance', performanceRoutes); + app.use('/api/projects', projectRoutes); + app.use('/api/tools', toolsRoute); + app.use('/api/users', userRoutes); + app.use('/api', uploadBgRoutes); + + // Swagger API 문서 + app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + explorer: true, + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'TK Work Management API', + swaggerOptions: { + persistAuthorization: true, + displayRequestDuration: true, + docExpansion: 'none', + filter: true, + showExtensions: true, + showCommonExtensions: true + } + })); + + app.get('/api-docs.json', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.send(swaggerSpec); + }); + + logger.info('라우트 설정 완료'); +} + +module.exports = setupRoutes; diff --git a/api.hyungi.net/dbPool.js b/api.hyungi.net/dbPool.js index 66efac7..79976ee 100644 --- a/api.hyungi.net/dbPool.js +++ b/api.hyungi.net/dbPool.js @@ -1,62 +1,11 @@ -// dbPool.js -require('dotenv').config(); -const mysql = require('mysql2/promise'); -const retry = require('async-retry'); +/** + * 데이터베이스 풀 (호환성 레거시 파일) + * + * @deprecated 이 파일은 하위 호환성을 위해 유지됩니다. + * 새로운 코드에서는 './config/database'를 직접 import하세요. + * + * @author TK-FB-Project + * @since 2025-12-11 + */ -let pool = null; - -async function initPool() { - if (pool) return pool; - - const { - DB_HOST, DB_PORT, DB_USER, - DB_PASSWORD, DB_NAME, - DB_SOCKET, DB_CONN_LIMIT = '10' - } = process.env; - - if (!DB_USER || !DB_PASSWORD || !DB_NAME) { - throw new Error('필수 환경변수(DB_USER, DB_PASSWORD, DB_NAME)가 없습니다.'); - } - if (!DB_SOCKET && !DB_HOST) { - throw new Error('DB_SOCKET이 없으면 DB_HOST가 반드시 필요합니다.'); - } - - await retry(async () => { - const config = { - user: DB_USER, - password: DB_PASSWORD, - database: DB_NAME, - waitForConnections: true, - connectionLimit: parseInt(DB_CONN_LIMIT, 10), - queueLimit: 0, - charset: 'utf8mb4' - }; - if (DB_SOCKET) { - config.socketPath = DB_SOCKET; - } else { - config.host = DB_HOST; - config.port = parseInt(DB_PORT, 10); - } - - pool = mysql.createPool(config); - - // 첫 연결 검증 - const conn = await pool.getConnection(); - await conn.query('SET NAMES utf8mb4'); - conn.release(); - - console.log(`✅ MariaDB 연결 성공: ${DB_SOCKET ? `socket=${DB_SOCKET}` : `${DB_HOST}:${DB_PORT}`}`); - }, { - retries: 5, - factor: 2, - minTimeout: 1000 - }); - - return pool; -} - -async function getDb() { - return initPool(); -} - -module.exports = { getDb }; \ No newline at end of file +module.exports = require('./config/database'); diff --git a/api.hyungi.net/index.js b/api.hyungi.net/index.js index cb64521..7e7661d 100644 --- a/api.hyungi.net/index.js +++ b/api.hyungi.net/index.js @@ -1,587 +1,101 @@ +/** + * TK-FB-Project API Server + * + * 작업 관리 시스템의 메인 서버 애플리케이션 + * + * @author TK-FB-Project + * @since 2025-12-11 + * @version 2.2.0 + */ + require('dotenv').config(); const express = require('express'); -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'); - -// Swagger 설정 -const swaggerUi = require('swagger-ui-express'); -const swaggerSpec = require('./config/swagger'); - -// 성능 최적화 모듈 -const compression = require('compression'); +const setupMiddlewares = require('./config/middleware'); +const setupRoutes = require('./config/routes'); +const { errorHandler } = require('./middlewares/errorHandler'); +const logger = require('./utils/logger'); const cache = require('./utils/cache'); const app = express(); +const PORT = process.env.PORT || 20005; -// 헬스체크와 개발용 엔드포인트는 CORS 이후에 등록 - -// ✅ 보안 헤더 설정 (Helmet) -app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], - scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], - imgSrc: ["'self'", "data:", "https:", "blob:"], - fontSrc: ["'self'", "https://fonts.gstatic.com"], - connectSrc: ["'self'", "https://api.technicalkorea.com"], - }, - }, - hsts: { - maxAge: 31536000, - includeSubDomains: true, - preload: true - } -})); - -// ✅ 성능 최적화 미들웨어 -app.use(compression({ - filter: (req, res) => { - if (req.headers['x-no-compression']) { - return false; - } - return compression.filter(req, res); - }, - level: 6, // 압축 레벨 (1-9, 6이 기본값) - threshold: 1024 // 1KB 이상만 압축 -})); - -// ✅ 요청 바디 용량 제한 확장 -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) { - // 개발 환경에서는 모든 origin 허용 - console.log('🌐 CORS Origin 요청:', origin); - - const allowedOrigins = [ - 'http://localhost:20000', // 웹 UI - 'http://localhost:3005', // API 서버 - 'http://localhost:3000', // 개발 포트 - 'http://127.0.0.1:20000', // 로컬호스트 대체 - ]; - - // origin이 없는 경우 (직접 접근) 허용 - if (!origin) { - console.log('✅ Origin 없음 - 허용'); - return callback(null, true); - } - - // 허용된 origin인지 확인 - if (allowedOrigins.includes(origin)) { - console.log('✅ 허용된 Origin:', origin); - return callback(null, true); - } - - // 개발 환경에서는 모든 localhost 허용 - if (origin.includes('localhost') || origin.includes('127.0.0.1')) { - console.log('✅ 로컬호스트 허용:', origin); - return callback(null, true); - } - - console.log('❌ 차단된 Origin:', origin); - callback(null, false); - }, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'] -})); - -// ✅ Health check (CORS 이후에 등록) -app.get('/api/health', (req, res) => { - console.log('🟢 Health check 호출됨!'); - res.status(200).json({ - status: 'healthy', - service: 'Hyungi API', - timestamp: new Date().toISOString() - }); -}); - -// ✅ 개발용 Ping 엔드포인트 -app.get('/api/ping', (req, res) => { - console.log('🏓 Ping 요청 받음!'); - res.status(200).json({ - message: 'pong', - timestamp: new Date().toISOString() - }); -}); - -// ✅ 개발용 DB 설정 엔드포인트 (임시 비활성화) -// app.post('/api/setup-attendance-db', async (req, res) => { -// // DB 설정 로직 임시 비활성화 -// }); - -// ✅ 서버 상태 엔드포인트 -app.get('/api/status', (req, res) => { - console.log('📊 Status 요청 받음!'); - res.health('running', { - service: 'Hyungi API', - version: '2.1.0', - environment: process.env.NODE_ENV || 'development' - }); -}); - -// ✅ CORS 설정: 허용 origin 명시 (수정된 버전) -//app.use(cors({ -// origin: function (origin, callback) { -// const allowedOrigins = process.env.ALLOWED_ORIGINS -// ? process.env.ALLOWED_ORIGINS.split(',') -// : [ -// 'http://localhost:3000', -// 'http://localhost:3005', -// 'http://web-ui', -// 'http://web-ui:80', -// 'http://web-ui:3001', // 실제 내부 포트 -// 'http://172.18.0.1', -// 'http://172.18.0.1:3001', -// 'http://172.18.0.2', // web-ui 컨테이너 IP -// 'http://172.18.0.2:3001', // web-ui 컨테이너 IP:포트 -// 'http://192.168.0.3', // 나스 외부 IP (포트 없음) -// 'http://192.168.0.3:80', // 나스 외부 접근 -// 'http://192.168.0.3:3001', // 나스 외부 접근 (실제 포트) -// 'http://192.168.0.3:5000', // 시놀로지 기본 포트 -// 'http://192.168.0.3:5001', // 시놀로지 HTTPS 포트 -// // 추가: 더 유연한 허용 -// 'http://192.168.0.3:3000', // 다른 포트들도 허용 -// 'http://192.168.0.3:8080', -// 'http://192.168.0.3:8000' -// ]; -// -// // 개발 환경에서는 모든 로컬 IP 허용 -// if (process.env.NODE_ENV === 'development' || !origin) { -// return callback(null, true); -// } -// -// // 192.168.x.x 대역 자동 허용 (시놀로지 환경) -// if (origin && origin.match(/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/)) { -// console.log('✅ 로컬 네트워크 IP 자동 허용:', origin); -// return callback(null, true); -// } -// -// if (allowedOrigins.includes(origin)) { -// callback(null, true); -// } else { -// console.error('❌ CORS 차단됨:', origin); -// console.log('허용된 Origins:', allowedOrigins); -// callback(new Error('CORS 차단됨: ' + origin)); -// } -// }, -// credentials: true -// })); - -// ✅ 신뢰할 수 있는 프록시 설정 (IP 주소 정확히 가져오기) +// Trust proxy for accurate IP addresses app.set('trust proxy', 1); -// ✅ API 속도 제한 설정 - 내부 시스템이므로 비활성화 -// Rate Limiting 제거 (내부 시스템, 제한된 사용자) -const apiLimiter = (req, res, next) => next(); // 통과 -const loginLimiter = (req, res, next) => next(); // 통과 +// 미들웨어 설정 +setupMiddlewares(app); -// ✅ 라우터 등록 -const authRoutes = require('./routes/authRoutes'); -const projectRoutes = require('./routes/projectRoutes'); -const workerRoutes = require('./routes/workerRoutes'); -const workReportRoutes = require('./routes/workReportRoutes'); -const toolsRoute = require('./routes/toolsRoute'); -const uploadRoutes = require('./routes/uploadRoutes'); -const uploadBgRoutes = require('./routes/uploadBgRoutes'); -const dailyIssueReportRoutes = require('./routes/dailyIssueReportRoutes'); -const issueTypeRoutes = require('./routes/issueTypeRoutes'); -const healthRoutes = require('./routes/healthRoutes'); -const dailyWorkReportRoutes = require('./routes/dailyWorkReportRoutes'); -const workAnalysisRoutes = require('./routes/workAnalysisRoutes'); -const analysisRoutes = require('./routes/analysisRoutes'); -const systemRoutes = require('./routes/systemRoutes'); // 새로운 분석 라우트 -const performanceRoutes = require('./routes/performanceRoutes'); // 성능 모니터링 라우트 -const userRoutes = require('./routes/userRoutes'); // 사용자 관리 라우트 +// 라우트 설정 +setupRoutes(app); -// 🔒 인증 미들웨어 가져오기 -const { verifyToken } = require('./middlewares/authMiddleware'); - -// ahn.hyungi.net 배포용 -app.use(express.static(path.join(__dirname, 'public'))); - -// ✅ 업로드된 파일 정적 라우팅 추가 (웹에서 이미지 접근 가능하게) -app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); - -// 🔒 활동 로깅 미들웨어 -const activityLogger = (req, res, next) => { - const start = Date.now(); - - res.on('finish', () => { - const duration = Date.now() - start; - const logData = { - timestamp: new Date().toISOString(), - method: req.method, - url: req.originalUrl, - status: res.statusCode, - duration: duration + 'ms', - ip: req.ip, - user: req.user?.username || 'anonymous' - }; - - // 성공/실패에 따른 로그 레벨 분기 - if (res.statusCode >= 400) { - console.error('[API Error]', logData); - } else if (res.statusCode >= 300) { - console.warn('[API Redirect]', logData); - } else { - console.log('[API Access]', logData); - } - }); - - next(); -}; - -// ===== 📋 미들웨어 적용 순서 수정 (🔥 핵심 수정 부분) ===== - -// 모든 API 요청에 활동 로거 적용 -app.use('/api/*', activityLogger); - -// 🔓 인증이 필요 없는 경로들을 먼저 등록 (순서 중요!) - -// Health check는 이미 맨 위에서 등록됨 - -// 🔓 로그인 관련 경로들 (인증 없이 접근 가능) -// 로그인 엔드포인트에 특별한 속도 제한 적용 -app.post('/api/auth/login', loginLimiter, (req, res, next) => { - console.log('🔓 로그인 요청 받음:', req.body.username); - authRoutes.handle(req, res, next); -}); - -// 기타 공개 인증 엔드포인트들 -app.use('/api/auth/refresh-token', loginLimiter); -app.use('/api/auth/check-password-strength', loginLimiter); - -// 나머지 인증 라우트 -app.use('/api/auth', authRoutes); - -// 🔧 DB 설정 라우트 (개발용 - 인증 없이 접근 가능) -app.use('/api/setup', require('./routes/setupRoutes')); - -// 🔒 일반 API 속도 제한 적용 -app.use('/api/', apiLimiter); - -// 🔒 인증이 필요한 모든 API에 대해 토큰 검증 (수정된 버전) -app.use('/api/*', (req, res, next) => { - console.log(`🔍 API 요청: ${req.method} ${req.originalUrl}`); - - // 🔓 인증이 필요 없는 경로들은 통과 (정확한 매칭) - const publicPaths = [ - '/api/auth/login', - '/api/auth/refresh-token', - '/api/auth/check-password-strength', - '/api/health', - '/api/ping', // 개발용 핑 - '/api/status', // 서버 상태 - '/api/setup/setup-attendance-db', - '/api/setup/setup-monthly-status', // DB 설정 (개발용) - '/api/setup/add-overtime-warning', // 12시간 초과 상태 추가 (개발용) - '/api/setup/migrate-existing-data', // 기존 데이터 마이그레이션 (개발용) - '/api/setup/check-data-status', // DB 상태 확인 (개발용) - '/api/monthly-status/calendar', // 월별 집계 테스트용 - '/api/monthly-status/daily-details' // 일별 상세 테스트용 - ]; - - // 정확한 경로 매칭 확인 - const isPublicPath = publicPaths.some(path => { - // 정확한 경로 또는 쿼리 파라미터가 있는 경우 - const isMatch = req.originalUrl === path || - req.originalUrl.startsWith(path + '?') || - req.originalUrl.startsWith(path + '/'); - if (isMatch) { - console.log(`🔓 Public path 허용: ${req.originalUrl}`); - } - return isMatch; - }); - - if (isPublicPath) { - return next(); - } - - // 나머지는 모두 인증 필요 - console.log(`🔒 인증 필요한 경로: ${req.originalUrl}`); - verifyToken(req, res, next); -}); - -// ===== 📊 모든 라우트 등록 (인증된 사용자만) ===== - -// 📝 일반 기능들 -app.use('/api/issue-reports', dailyIssueReportRoutes); -app.use('/api/issue-types', issueTypeRoutes); - -// 👥 기본 데이터들 (모든 인증된 사용자) -app.use('/api/workers', workerRoutes); -app.use('/api/daily-work-reports', dailyWorkReportRoutes); -app.use('/api/work-analysis', workAnalysisRoutes); -app.use('/api/analysis', analysisRoutes); // 새로운 분석 라우트 등록 -app.use('/api/daily-work-reports-analysis', require('./routes/workReportAnalysisRoutes')); // 데일리 워크 레포트 분석 라우트 -app.use('/api/attendance', require('./routes/attendanceRoutes')); // 근태 관리 라우트 -app.use('/api/monthly-status', require('./routes/monthlyStatusRoutes')); // 월별 상태 집계 라우트 - -// 📊 리포트 및 분석 -app.use('/api/workreports', workReportRoutes); - -// 🔧 시스템 관리 (시스템 권한만) -app.use('/api/system', systemRoutes); -app.use('/api/uploads', uploadRoutes); - -// 📊 성능 모니터링 (관리자 권한) -app.use('/api/performance', performanceRoutes); - -// ⚙️ 시스템 데이터들 (모든 인증된 사용자) -app.use('/api/projects', projectRoutes); -app.use('/api/tools', toolsRoute); - -// 👤 사용자 관리 API (관리자 전용) -app.use('/api/users', userRoutes); - -// 📤 파일 업로드 -app.use('/api', uploadBgRoutes); - -// ===== 📚 Swagger API 문서 ===== -app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { - explorer: true, - customCss: '.swagger-ui .topbar { display: none }', - customSiteTitle: 'TK Work Management API', - swaggerOptions: { - persistAuthorization: true, - displayRequestDuration: true, - docExpansion: 'none', - filter: true, - showExtensions: true, - showCommonExtensions: true - } -})); - -// Swagger JSON 스펙 제공 -app.get('/api-docs.json', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.send(swaggerSpec); -}); - -// ===== 🚨 에러 핸들러 (모든 라우트 뒤에 위치) ===== -app.use(errorMiddleware); - -// ===== 🔍 API 정보 엔드포인트 ===== -app.get('/api', (req, res) => { - res.json({ - name: 'Technical Korea Work Management API', - version: '2.1.0', - description: '보안이 강화된 생산관리 시스템 API', - timestamp: new Date().toISOString(), - security: { - authentication: 'JWT Bearer Token', - rateLimit: { - general: '100 requests per 15 minutes', - login: '5 attempts per 15 minutes' - }, - cors: 'Configured for specific origins', - headers: 'Security headers enabled (Helmet)' - }, - user: { - username: req.user?.username || 'anonymous', - access_level: req.user?.access_level || 'none', - worker_id: req.user?.worker_id || null - }, - endpoints: { - auth: { - login: 'POST /api/auth/login', - logout: 'POST /api/auth/logout', - refreshToken: 'POST /api/auth/refresh-token', - changePassword: 'POST /api/auth/change-password', - adminChangePassword: 'POST /api/auth/admin/change-password', - checkPasswordStrength: 'POST /api/auth/check-password-strength', - me: 'GET /api/auth/me', - users: 'GET /api/auth/users', - register: 'POST /api/auth/register', - updateUser: 'PUT /api/auth/users/:id', - deleteUser: 'DELETE /api/auth/users/:id', - loginHistory: 'GET /api/auth/login-history' - }, - dailyWorkReports: { - workTypes: 'GET /api/daily-work-reports/work-types', - workStatusTypes: 'GET /api/daily-work-reports/work-status-types', - errorTypes: 'GET /api/daily-work-reports/error-types', - create: 'POST /api/daily-work-reports', - search: 'GET /api/daily-work-reports/search', - summary: 'GET /api/daily-work-reports/summary', - byDate: 'GET /api/daily-work-reports/date/:date', - update: 'PUT /api/daily-work-reports/:id', - delete: 'DELETE /api/daily-work-reports/:id' - }, - workAnalysis: { - stats: 'GET /api/work-analysis/stats?start=YYYY-MM-DD&end=YYYY-MM-DD', - dailyTrend: 'GET /api/work-analysis/daily-trend?start=YYYY-MM-DD&end=YYYY-MM-DD', - workerStats: 'GET /api/work-analysis/worker-stats?start=YYYY-MM-DD&end=YYYY-MM-DD', - projectStats: 'GET /api/work-analysis/project-stats?start=YYYY-MM-DD&end=YYYY-MM-DD', - workTypeStats: 'GET /api/work-analysis/work-type-stats?start=YYYY-MM-DD&end=YYYY-MM-DD', - recentWork: 'GET /api/work-analysis/recent-work?start=YYYY-MM-DD&end=YYYY-MM-DD&limit=10', - weekdayPattern: 'GET /api/work-analysis/weekday-pattern?start=YYYY-MM-DD&end=YYYY-MM-DD', - errorAnalysis: 'GET /api/work-analysis/error-analysis?start=YYYY-MM-DD&end=YYYY-MM-DD', - monthlyComparison: 'GET /api/work-analysis/monthly-comparison?year=YYYY', - workerSpecialization: 'GET /api/work-analysis/worker-specialization?start=YYYY-MM-DD&end=YYYY-MM-DD', - dashboard: 'GET /api/work-analysis/dashboard?start=YYYY-MM-DD&end=YYYY-MM-DD', - health: 'GET /api/work-analysis/health' - }, - workers: 'GET/POST/PUT/DELETE /api/workers', - projects: 'GET/POST/PUT/DELETE /api/projects', - issues: 'GET/POST/PUT/DELETE /api/issue-reports', - reports: 'GET /api/workreports', - uploads: 'POST /api/uploads' - }, - note: '모든 API는 로그인 후 접근 가능합니다. 자세한 API 문서는 관리자에게 문의하세요.' - }); -}); - -// ===== 🏠 메인 페이지 라우트 ===== -app.get('/', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); -}); - -// ✅ 서버 실행 -const PORT = process.env.PORT || 3005; -const server = app.listen(PORT, () => { - console.log(` -🚀 Technical Korea Work Management System v2.1.0 -📍 서버가 포트 ${PORT}에서 실행 중입니다. -🌐 접속 URL: http://localhost:${PORT} -📊 API 문서: http://localhost:${PORT}/api - -🔒 보안 기능: - ✅ JWT 토큰 인증 - ✅ 로그인 실패 제한 (5회) - ✅ API 속도 제한 - ✅ 보안 헤더 (Helmet) - ✅ CORS 설정 (192.168.0.3:3001 허용) - ✅ 활동 로깅 - -📋 새로운 기능: - 🔐 비밀번호 변경 (본인/관리자) - 🔄 토큰 갱신 (Refresh Token) - 📊 로그인 이력 조회 - 💪 비밀번호 강도 체크 - `); -}).on('error', (err) => { - console.error('❌ 서버 실행 중 오류 발생:', err); - if (err.code === 'EADDRINUSE') { - console.error(`포트 ${PORT}이(가) 이미 사용 중입니다.`); - } -}); - -// ===== 🚨 에러 핸들링 ===== +// 에러 핸들러 (모든 라우트 이후에 위치) +app.use(errorHandler); // 404 핸들러 app.use((req, res) => { - console.log(`[404] ${req.method} ${req.originalUrl} - IP: ${req.ip}`); - - if (req.originalUrl.startsWith('/api/')) { - res.status(404).json({ - error: 'API 엔드포인트를 찾을 수 없습니다.', - path: req.originalUrl, - available: '/api', - timestamp: new Date().toISOString() - }); - } else { - res.status(404).json({ - error: '요청하신 페이지를 찾을 수 없습니다.', - timestamp: new Date().toISOString() - }); - } -}); - -// 전역 에러 핸들러 -app.use((err, req, res, next) => { - const errorId = Date.now().toString(36); - - console.error(`[ERROR ${errorId}] ${new Date().toISOString()}:`, { - message: err.message, - stack: process.env.NODE_ENV === 'development' ? err.stack : undefined, - url: req.originalUrl, - method: req.method, - ip: req.ip, - user: req.user?.username || 'anonymous' - }); - - // CORS 에러 -// if (err.message && err.message.includes('CORS 차단됨')) { -// return res.status(403).json({ -// error: 'CORS 정책에 의해 차단되었습니다.', -// message: 'API 접근이 허용되지 않은 도메인입니다.', -// errorId -// }); -// } - - // JWT 에러 - if (err.name === 'JsonWebTokenError') { - return res.status(401).json({ - error: '유효하지 않은 토큰입니다.', - errorId - }); - } - - if (err.name === 'TokenExpiredError') { - return res.status(401).json({ - error: '토큰이 만료되었습니다. 다시 로그인해주세요.', - errorId - }); - } - - // 요청 크기 초과 - if (err.type === 'entity.too.large') { - return res.status(413).json({ - error: '요청 크기가 너무 큽니다. 50MB 이하로 줄여주세요.', - errorId - }); - } - - // 일반 서버 에러 - res.status(err.status || 500).json({ - error: '서버 오류가 발생했습니다.', - message: process.env.NODE_ENV === 'development' ? err.message : '관리자에게 문의하세요.', - errorId, - timestamp: new Date().toISOString() + logger.warn('404 Not Found', { url: req.originalUrl, method: req.method }); + res.status(404).json({ + success: false, + error: '요청하신 경로를 찾을 수 없습니다', + path: req.originalUrl }); }); -// ===== 🔄 Graceful Shutdown ===== -const gracefulShutdown = () => { - console.log('\n🛑 서버 종료 신호를 받았습니다...'); - - server.close(() => { +// 서버 시작 +const server = app.listen(PORT, () => { + logger.info(`서버 시작 완료`, { + port: PORT, + env: process.env.NODE_ENV || 'development', + nodeVersion: process.version + }); + console.log(`\n🚀 서버가 포트 ${PORT}에서 실행 중입니다.`); + console.log(`📚 API 문서: http://localhost:${PORT}/api-docs\n`); +}); + +// Graceful Shutdown +const gracefulShutdown = (signal) => { + logger.info(`${signal} 신호 수신 - 서버 종료 시작`); + console.log(`\n🛑 ${signal} 신호를 받았습니다. 서버를 종료합니다...`); + + server.close(async () => { + logger.info('HTTP 서버 종료 완료'); console.log('✅ HTTP 서버가 정상적으로 종료되었습니다.'); - - // DB 연결 종료 등 추가 정리 작업 - // 예: db.end(), redis.quit() 등 - + + // 리소스 정리 + try { + // DB 연결 종료는 각 요청에서 pool을 사용하므로 불필요 + // Redis 종료 (사용 중인 경우) + if (cache.redis) { + await cache.redis.quit(); + logger.info('캐시 시스템 종료 완료'); + } + } catch (error) { + logger.error('리소스 정리 중 오류 발생', { error: error.message }); + } + process.exit(0); }); - + // 30초 후 강제 종료 setTimeout(() => { + logger.error('강제 종료 - 정상 종료 시간 초과'); console.error('❌ 정상 종료 실패, 강제 종료합니다.'); process.exit(1); }, 30000); }; -process.on('SIGTERM', gracefulShutdown); -process.on('SIGINT', gracefulShutdown); +// 시그널 핸들러 등록 +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); // 처리되지 않은 Promise 거부 process.on('unhandledRejection', (reason, promise) => { - console.error('처리되지 않은 Promise 거부:', reason); - // 개발 환경에서는 크래시, 프로덕션에서는 로그만 + logger.error('처리되지 않은 Promise 거부', { + reason: reason, + promise: promise + }); + console.error('⚠️ 처리되지 않은 Promise 거부:', reason); + if (process.env.NODE_ENV === 'development') { process.exit(1); } @@ -589,18 +103,26 @@ process.on('unhandledRejection', (reason, promise) => { // 처리되지 않은 예외 process.on('uncaughtException', (error) => { - console.error('처리되지 않은 예외:', error); - gracefulShutdown(); + logger.error('처리되지 않은 예외', { + error: error.message, + stack: error.stack + }); + console.error('💥 처리되지 않은 예외:', error); + gracefulShutdown('UNCAUGHT_EXCEPTION'); }); -// ✅ 캐시 시스템 초기화 +// 캐시 시스템 초기화 (선택적) (async () => { try { - await cache.initRedis(); - console.log('🚀 캐시 시스템 초기화 완료'); + if (cache.initRedis) { + await cache.initRedis(); + logger.info('캐시 시스템 초기화 완료'); + console.log('✅ 캐시 시스템 초기화 완료'); + } } catch (error) { - console.warn('캐시 시스템 초기화 실패:', error.message); + logger.warn('캐시 시스템 초기화 실패 - 계속 진행', { error: error.message }); + console.warn('⚠️ 캐시 시스템 초기화 실패:', error.message); } })(); -module.exports = app; \ No newline at end of file +module.exports = app;