Files
TK-FB-Project/api.hyungi.net/index.js
Hyungi Ahn 746e09420b feat: 캘린더 기반 작업 현황 확인 시스템 구현
- 월별 캘린더 UI로 작업 현황을 한눈에 확인 가능
- 미입력(빨강), 부분입력(주황), 확인필요(보라), 이상없음(초록) 상태 표시
- 범례 아이콘(●)을 사용한 직관적인 상태 표시
- 날짜 클릭 시 해당일 작업자별 상세 현황 모달
- 작업자 클릭 시 개별 작업 입력/수정 모달
- 휴가 처리 기능 (연차, 반차, 반반차, 조퇴)
- 월별 집계 데이터 최적화로 API 호출 최소화

백엔드:
- monthly_worker_status, monthly_summary 테이블 추가
- 자동 집계 stored procedure 및 trigger 구현
- 확인필요(12시간 초과) 상태 감지 로직
- 출석 관리 시스템 확장

프론트엔드:
- 캘린더 그리드 UI 구현
- 상태별 색상 및 아이콘 표시
- 모달 기반 상세 정보 표시
- 반응형 디자인 적용
2025-11-04 10:12:07 +09:00

619 lines
21 KiB
JavaScript

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 cache = require('./utils/cache');
const app = express();
// 헬스체크와 개발용 엔드포인트는 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 주소 정확히 가져오기)
app.set('trust proxy', 1);
// ✅ API 속도 제한 설정
// 일반 API 속도 제한
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: process.env.RATE_LIMIT_MAX_REQUESTS || 100,
message: 'API 요청 한도를 초과했습니다. 잠시 후 다시 시도하세요.',
standardHeaders: true,
legacyHeaders: false,
});
// 로그인 API 속도 제한 (개발 환경에서 완화됨)
const loginLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5분으로 단축
max: process.env.LOGIN_RATE_LIMIT_MAX_REQUESTS || 20, // 5 -> 20으로 증가
message: '너무 많은 로그인 시도입니다. 5분 후에 다시 시도하세요.',
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // 성공한 요청은 카운트하지 않음
});
// ✅ 라우터 등록
const authRoutes = require('./routes/authRoutes');
const projectRoutes = require('./routes/projectRoutes');
const workerRoutes = require('./routes/workerRoutes');
const taskRoutes = require('./routes/taskRoutes');
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 { 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/tasks', taskRoutes);
app.use('/api/tools', toolsRoute);
// 📤 파일 업로드
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}이(가) 이미 사용 중입니다.`);
}
});
// ===== 🚨 에러 핸들링 =====
// 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()
});
});
// ===== 🔄 Graceful Shutdown =====
const gracefulShutdown = () => {
console.log('\n🛑 서버 종료 신호를 받았습니다...');
server.close(() => {
console.log('✅ HTTP 서버가 정상적으로 종료되었습니다.');
// DB 연결 종료 등 추가 정리 작업
// 예: db.end(), redis.quit() 등
process.exit(0);
});
// 30초 후 강제 종료
setTimeout(() => {
console.error('❌ 정상 종료 실패, 강제 종료합니다.');
process.exit(1);
}, 30000);
};
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
// 처리되지 않은 Promise 거부
process.on('unhandledRejection', (reason, promise) => {
console.error('처리되지 않은 Promise 거부:', reason);
// 개발 환경에서는 크래시, 프로덕션에서는 로그만
if (process.env.NODE_ENV === 'development') {
process.exit(1);
}
});
// 처리되지 않은 예외
process.on('uncaughtException', (error) => {
console.error('처리되지 않은 예외:', error);
gracefulShutdown();
});
// ✅ 캐시 시스템 초기화
(async () => {
try {
await cache.initRedis();
console.log('🚀 캐시 시스템 초기화 완료');
} catch (error) {
console.warn('캐시 시스템 초기화 실패:', error.message);
}
})();
module.exports = app;