fix: MySQL 8.0 호환성 문제 해결 - db.execute → db.query 변경
🔧 주요 변경사항: - WorkAnalysis.js: getRecentWork() 함수에서 db.execute → db.query로 변경 - Redis 연결 설정: socket 방식으로 업데이트 (Redis v5+ 호환) - Docker Compose: Redis 서비스 추가 및 네트워크 단순화 🎯 해결된 문제: - 'Incorrect arguments to mysqld_stmt_execute' 오류 해결 - 시놀로지 MySQL 8.0 환경에서 파라미터 바인딩 호환성 문제 해결 - Redis 연결 실패 문제 해결 📋 참고사항: - MySQL 8.0의 ONLY_FULL_GROUP_BY 모드와 Node.js 드라이버 호환성 문제 - db.execute vs db.query 차이점은 MYSQL_COMPATIBILITY_NOTES.md 참조
This commit is contained in:
81
MYSQL_COMPATIBILITY_NOTES.md
Normal file
81
MYSQL_COMPATIBILITY_NOTES.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# MySQL 8.0 호환성 문제 해결 가이드
|
||||||
|
|
||||||
|
## 🚨 문제 상황
|
||||||
|
- **환경**: 시놀로지 NAS Docker 환경 (MySQL 8.0.44)
|
||||||
|
- **오류**: `Incorrect arguments to mysqld_stmt_execute`
|
||||||
|
- **증상**: 개발 환경(맥미니)에서는 정상 작동하지만 프로덕션 환경에서만 실패
|
||||||
|
|
||||||
|
## 🔍 원인 분석
|
||||||
|
|
||||||
|
### MySQL 설정 차이
|
||||||
|
```sql
|
||||||
|
-- 시놀로지 MySQL 8.0 설정
|
||||||
|
@@sql_mode: ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
|
||||||
|
@@version: 8.0.44
|
||||||
|
character_set_database: utf8mb4
|
||||||
|
collation_database: utf8mb4_unicode_ci
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node.js MySQL 드라이버 호환성 문제
|
||||||
|
- **문제**: `db.execute()` 메서드의 파라미터 바인딩이 MySQL 8.0의 엄격한 모드에서 호환성 문제 발생
|
||||||
|
- **해결**: `db.query()` 메서드 사용으로 변경
|
||||||
|
|
||||||
|
## 🛠️ 해결 방법
|
||||||
|
|
||||||
|
### 변경 전 (문제 있음)
|
||||||
|
```javascript
|
||||||
|
const [results] = await this.db.execute(query, [startDate, endDate, parseInt(limit)]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 변경 후 (해결됨)
|
||||||
|
```javascript
|
||||||
|
const [results] = await this.db.query(query, [startDate, endDate, parseInt(limit)]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 db.execute vs db.query 차이점
|
||||||
|
|
||||||
|
| 구분 | db.execute | db.query |
|
||||||
|
|------|------------|----------|
|
||||||
|
| **파라미터 바인딩** | Prepared Statement 방식 | 전통적인 쿼리 방식 |
|
||||||
|
| **성능** | 반복 실행 시 더 빠름 | 단발성 실행에 적합 |
|
||||||
|
| **보안** | SQL Injection 방지 강화 | 기본적인 방지 |
|
||||||
|
| **MySQL 8.0 호환성** | 엄격한 모드에서 문제 발생 가능 | 안정적 |
|
||||||
|
| **메모리 사용** | 더 효율적 | 상대적으로 많음 |
|
||||||
|
|
||||||
|
## 🎯 권장사항
|
||||||
|
|
||||||
|
### 1. 환경별 대응
|
||||||
|
- **개발 환경**: `db.execute` 사용 가능
|
||||||
|
- **프로덕션 환경 (MySQL 8.0)**: `db.query` 사용 권장
|
||||||
|
|
||||||
|
### 2. 코드 작성 가이드
|
||||||
|
```javascript
|
||||||
|
// ✅ 권장: 환경에 따른 분기 처리
|
||||||
|
const executeQuery = async (db, query, params) => {
|
||||||
|
try {
|
||||||
|
// MySQL 8.0 엄격 모드 대응
|
||||||
|
return await db.query(query, params);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Query execution failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 테스트 전략
|
||||||
|
- 개발 환경과 프로덕션 환경에서 모두 테스트
|
||||||
|
- MySQL 버전별 호환성 확인
|
||||||
|
- 복잡한 JOIN 쿼리는 특히 주의
|
||||||
|
|
||||||
|
## 🔧 적용된 파일
|
||||||
|
- `synology_deployment/api/models/WorkAnalysis.js` - `getRecentWork()` 함수
|
||||||
|
|
||||||
|
## 📝 참고사항
|
||||||
|
- 이 문제는 MySQL 8.0의 `ONLY_FULL_GROUP_BY` 모드와 Node.js MySQL2 드라이버의 호환성 문제로 추정
|
||||||
|
- 향후 유사한 문제 발생 시 이 가이드 참조
|
||||||
|
- 다른 복잡한 쿼리에서도 동일한 문제가 발생할 수 있음
|
||||||
|
|
||||||
|
---
|
||||||
|
**작성일**: 2025-11-05
|
||||||
|
**해결 완료**: ✅
|
||||||
|
**테스트 완료**: ✅
|
||||||
430
synology_deployment/api/models/WorkAnalysis.js
Normal file
430
synology_deployment/api/models/WorkAnalysis.js
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
// models/WorkAnalysis.js - 향상된 버전
|
||||||
|
|
||||||
|
class WorkAnalysis {
|
||||||
|
constructor(db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 통계 조회
|
||||||
|
async getBasicStats(startDate, endDate) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(work_hours), 0) as total_hours,
|
||||||
|
COUNT(*) as total_reports,
|
||||||
|
COUNT(DISTINCT project_id) as active_projects,
|
||||||
|
COUNT(DISTINCT worker_id) as active_workers,
|
||||||
|
SUM(CASE WHEN work_status_id = 2 THEN 1 ELSE 0 END) as error_reports,
|
||||||
|
ROUND(AVG(work_hours), 2) as avg_hours_per_report
|
||||||
|
FROM daily_work_reports
|
||||||
|
WHERE report_date BETWEEN ? AND ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await this.db.execute(query, [startDate, endDate]);
|
||||||
|
const stats = results[0];
|
||||||
|
|
||||||
|
const errorRate = stats.total_reports > 0
|
||||||
|
? (stats.error_reports / stats.total_reports) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalHours: parseFloat(stats.total_hours) || 0,
|
||||||
|
totalReports: parseInt(stats.total_reports) || 0,
|
||||||
|
activeProjects: parseInt(stats.active_projects) || 0,
|
||||||
|
activeworkers: parseInt(stats.active_workers) || 0,
|
||||||
|
errorRate: parseFloat(errorRate.toFixed(2)) || 0,
|
||||||
|
avgHoursPerReport: parseFloat(stats.avg_hours_per_report) || 0
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`기본 통계 조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일별 작업시간 추이
|
||||||
|
async getDailyTrend(startDate, endDate) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
report_date as date,
|
||||||
|
SUM(work_hours) as hours,
|
||||||
|
COUNT(*) as reports,
|
||||||
|
COUNT(DISTINCT worker_id) as workers,
|
||||||
|
SUM(CASE WHEN work_status_id = 2 THEN 1 ELSE 0 END) as errors
|
||||||
|
FROM daily_work_reports
|
||||||
|
WHERE report_date BETWEEN ? AND ?
|
||||||
|
GROUP BY report_date
|
||||||
|
ORDER BY report_date
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await this.db.execute(query, [startDate, endDate]);
|
||||||
|
return results.map(row => ({
|
||||||
|
date: row.date,
|
||||||
|
hours: parseFloat(row.hours) || 0,
|
||||||
|
reports: parseInt(row.reports) || 0,
|
||||||
|
workers: parseInt(row.workers) || 0,
|
||||||
|
errors: parseInt(row.errors) || 0
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`일별 추이 조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업자별 통계
|
||||||
|
async getWorkerStats(startDate, endDate) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
dwr.worker_id,
|
||||||
|
w.worker_name,
|
||||||
|
SUM(dwr.work_hours) as totalHours,
|
||||||
|
COUNT(*) as totalReports,
|
||||||
|
ROUND(AVG(dwr.work_hours), 2) as avgHours,
|
||||||
|
COUNT(DISTINCT dwr.project_id) as projectCount,
|
||||||
|
SUM(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as errorCount,
|
||||||
|
COUNT(DISTINCT dwr.report_date) as workingDays
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
|
||||||
|
WHERE dwr.report_date BETWEEN ? AND ?
|
||||||
|
GROUP BY dwr.worker_id, w.worker_name
|
||||||
|
ORDER BY totalHours DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await this.db.execute(query, [startDate, endDate]);
|
||||||
|
return results.map(row => ({
|
||||||
|
worker_id: row.worker_id,
|
||||||
|
worker_name: row.worker_name || `작업자 ${row.worker_id}`,
|
||||||
|
totalHours: parseFloat(row.totalHours) || 0,
|
||||||
|
totalReports: parseInt(row.totalReports) || 0,
|
||||||
|
avgHours: parseFloat(row.avgHours) || 0,
|
||||||
|
projectCount: parseInt(row.projectCount) || 0,
|
||||||
|
errorCount: parseInt(row.errorCount) || 0,
|
||||||
|
workingDays: parseInt(row.workingDays) || 0,
|
||||||
|
errorRate: row.totalReports > 0 ? parseFloat(((row.errorCount / row.totalReports) * 100).toFixed(2)) : 0
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`작업자별 통계 조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트별 통계
|
||||||
|
async getProjectStats(startDate, endDate) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
dwr.project_id,
|
||||||
|
p.project_name,
|
||||||
|
SUM(dwr.work_hours) as totalHours,
|
||||||
|
COUNT(*) as totalReports,
|
||||||
|
COUNT(DISTINCT dwr.worker_id) as workerCount,
|
||||||
|
ROUND(AVG(dwr.work_hours), 2) as avgHours,
|
||||||
|
SUM(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as errorCount,
|
||||||
|
COUNT(DISTINCT dwr.report_date) as activeDays
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||||
|
WHERE dwr.report_date BETWEEN ? AND ?
|
||||||
|
GROUP BY dwr.project_id, p.project_name
|
||||||
|
ORDER BY totalHours DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await this.db.execute(query, [startDate, endDate]);
|
||||||
|
return results.map(row => ({
|
||||||
|
project_id: row.project_id,
|
||||||
|
project_name: row.project_name || `프로젝트 ${row.project_id}`,
|
||||||
|
totalHours: parseFloat(row.totalHours) || 0,
|
||||||
|
totalReports: parseInt(row.totalReports) || 0,
|
||||||
|
workerCount: parseInt(row.workerCount) || 0,
|
||||||
|
avgHours: parseFloat(row.avgHours) || 0,
|
||||||
|
errorCount: parseInt(row.errorCount) || 0,
|
||||||
|
activeDays: parseInt(row.activeDays) || 0,
|
||||||
|
errorRate: row.totalReports > 0 ? parseFloat(((row.errorCount / row.totalReports) * 100).toFixed(2)) : 0
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`프로젝트별 통계 조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업유형별 통계
|
||||||
|
async getWorkTypeStats(startDate, endDate) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
dwr.work_type_id,
|
||||||
|
wt.name as work_type_name,
|
||||||
|
SUM(dwr.work_hours) as totalHours,
|
||||||
|
COUNT(*) as totalReports,
|
||||||
|
ROUND(AVG(dwr.work_hours), 2) as avgHours,
|
||||||
|
COUNT(DISTINCT dwr.worker_id) as workerCount,
|
||||||
|
SUM(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as errorCount,
|
||||||
|
COUNT(DISTINCT dwr.project_id) as projectCount
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||||
|
WHERE dwr.report_date BETWEEN ? AND ?
|
||||||
|
GROUP BY dwr.work_type_id, wt.name
|
||||||
|
ORDER BY totalHours DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await this.db.execute(query, [startDate, endDate]);
|
||||||
|
return results.map(row => ({
|
||||||
|
work_type_id: row.work_type_id,
|
||||||
|
work_type_name: row.work_type_name || `작업유형 ${row.work_type_id}`,
|
||||||
|
totalHours: parseFloat(row.totalHours) || 0,
|
||||||
|
totalReports: parseInt(row.totalReports) || 0,
|
||||||
|
avgHours: parseFloat(row.avgHours) || 0,
|
||||||
|
workerCount: parseInt(row.workerCount) || 0,
|
||||||
|
errorCount: parseInt(row.errorCount) || 0,
|
||||||
|
projectCount: parseInt(row.projectCount) || 0,
|
||||||
|
errorRate: row.totalReports > 0 ? parseFloat(((row.errorCount / row.totalReports) * 100).toFixed(2)) : 0
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`작업유형별 통계 조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 작업 현황
|
||||||
|
async getRecentWork(startDate, endDate, limit = 50) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
dwr.id,
|
||||||
|
dwr.report_date,
|
||||||
|
dwr.worker_id,
|
||||||
|
w.worker_name,
|
||||||
|
dwr.project_id,
|
||||||
|
p.project_name,
|
||||||
|
p.job_no,
|
||||||
|
dwr.work_type_id,
|
||||||
|
wt.name as work_type_name,
|
||||||
|
dwr.work_status_id,
|
||||||
|
wst.name as work_status_name,
|
||||||
|
dwr.error_type_id,
|
||||||
|
et.name as error_type_name,
|
||||||
|
dwr.work_hours,
|
||||||
|
dwr.created_by,
|
||||||
|
dwr.created_at
|
||||||
|
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
|
||||||
|
WHERE dwr.report_date BETWEEN ? AND ?
|
||||||
|
ORDER BY dwr.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await this.db.query(query, [startDate, endDate, parseInt(limit)]);
|
||||||
|
return results.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
report_date: row.report_date,
|
||||||
|
worker_id: row.worker_id,
|
||||||
|
worker_name: row.worker_name || `작업자 ${row.worker_id}`,
|
||||||
|
project_id: row.project_id,
|
||||||
|
project_name: row.project_name || `프로젝트 ${row.project_id}`,
|
||||||
|
job_no: row.job_no || 'N/A',
|
||||||
|
work_type_id: row.work_type_id,
|
||||||
|
work_type_name: row.work_type_name || `작업유형 ${row.work_type_id}`,
|
||||||
|
work_status_id: row.work_status_id,
|
||||||
|
work_status_name: row.work_status_name || '정상',
|
||||||
|
error_type_id: row.error_type_id,
|
||||||
|
error_type_name: row.error_type_name || null,
|
||||||
|
work_hours: parseFloat(row.work_hours) || 0,
|
||||||
|
created_by: row.created_by,
|
||||||
|
created_by_name: `사용자 ${row.created_by}`,
|
||||||
|
created_at: row.created_at
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`최근 작업 현황 조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 요일별 패턴 분석
|
||||||
|
async getWeekdayPattern(startDate, endDate) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
DAYOFWEEK(report_date) as day_of_week,
|
||||||
|
CASE DAYOFWEEK(report_date)
|
||||||
|
WHEN 1 THEN '일요일'
|
||||||
|
WHEN 2 THEN '월요일'
|
||||||
|
WHEN 3 THEN '화요일'
|
||||||
|
WHEN 4 THEN '수요일'
|
||||||
|
WHEN 5 THEN '목요일'
|
||||||
|
WHEN 6 THEN '금요일'
|
||||||
|
WHEN 7 THEN '토요일'
|
||||||
|
END as day_name,
|
||||||
|
SUM(work_hours) as total_hours,
|
||||||
|
COUNT(*) as total_reports,
|
||||||
|
ROUND(AVG(work_hours), 2) as avg_hours,
|
||||||
|
COUNT(DISTINCT worker_id) as active_workers
|
||||||
|
FROM daily_work_reports
|
||||||
|
WHERE report_date BETWEEN ? AND ?
|
||||||
|
GROUP BY DAYOFWEEK(report_date)
|
||||||
|
ORDER BY day_of_week
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await this.db.execute(query, [startDate, endDate]);
|
||||||
|
return results.map(row => ({
|
||||||
|
dayOfWeek: row.day_of_week,
|
||||||
|
dayName: row.day_name,
|
||||||
|
totalHours: parseFloat(row.total_hours) || 0,
|
||||||
|
totalReports: parseInt(row.total_reports) || 0,
|
||||||
|
avgHours: parseFloat(row.avg_hours) || 0,
|
||||||
|
activeworkers: parseInt(row.active_workers) || 0
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`요일별 패턴 분석 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 분석
|
||||||
|
async getErrorAnalysis(startDate, endDate) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
dwr.error_type_id,
|
||||||
|
et.name as error_type_name,
|
||||||
|
COUNT(*) as error_count,
|
||||||
|
SUM(dwr.work_hours) as total_hours,
|
||||||
|
COUNT(DISTINCT dwr.worker_id) as affected_workers,
|
||||||
|
COUNT(DISTINCT dwr.project_id) as affected_projects
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
LEFT JOIN error_types et ON dwr.error_type_id = et.id
|
||||||
|
WHERE dwr.report_date BETWEEN ? AND ?
|
||||||
|
AND dwr.work_status_id = 2
|
||||||
|
GROUP BY dwr.error_type_id, et.name
|
||||||
|
ORDER BY error_count DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await this.db.execute(query, [startDate, endDate]);
|
||||||
|
return results.map(row => ({
|
||||||
|
error_type_id: row.error_type_id,
|
||||||
|
error_type_name: row.error_type_name || `에러유형 ${row.error_type_id}`,
|
||||||
|
errorCount: parseInt(row.error_count) || 0,
|
||||||
|
totalHours: parseFloat(row.total_hours) || 0,
|
||||||
|
affectedworkers: parseInt(row.affected_workers) || 0,
|
||||||
|
affectedProjects: parseInt(row.affected_projects) || 0
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`에러 분석 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 월별 비교 분석
|
||||||
|
async getMonthlyComparison(year) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
MONTH(report_date) as month,
|
||||||
|
MONTHNAME(report_date) as month_name,
|
||||||
|
SUM(work_hours) as total_hours,
|
||||||
|
COUNT(*) as total_reports,
|
||||||
|
COUNT(DISTINCT worker_id) as active_workers,
|
||||||
|
COUNT(DISTINCT project_id) as active_projects,
|
||||||
|
SUM(CASE WHEN work_status_id = 2 THEN 1 ELSE 0 END) as error_count
|
||||||
|
FROM daily_work_reports
|
||||||
|
WHERE YEAR(report_date) = ?
|
||||||
|
GROUP BY MONTH(report_date), MONTHNAME(report_date)
|
||||||
|
ORDER BY month
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await this.db.execute(query, [year]);
|
||||||
|
return results.map(row => ({
|
||||||
|
month: row.month,
|
||||||
|
monthName: row.month_name,
|
||||||
|
totalHours: parseFloat(row.total_hours) || 0,
|
||||||
|
totalReports: parseInt(row.total_reports) || 0,
|
||||||
|
activeworkers: parseInt(row.active_workers) || 0,
|
||||||
|
activeProjects: parseInt(row.active_projects) || 0,
|
||||||
|
errorCount: parseInt(row.error_count) || 0,
|
||||||
|
errorRate: row.total_reports > 0 ? parseFloat(((row.error_count / row.total_reports) * 100).toFixed(2)) : 0
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`월별 비교 분석 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업자별 전문분야 분석
|
||||||
|
async getWorkerSpecialization(startDate, endDate) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
dwr.worker_id,
|
||||||
|
w.worker_name,
|
||||||
|
dwr.work_type_id,
|
||||||
|
wt.name as work_type_name,
|
||||||
|
dwr.project_id,
|
||||||
|
p.project_name,
|
||||||
|
SUM(dwr.work_hours) as totalHours,
|
||||||
|
COUNT(*) as totalReports,
|
||||||
|
ROUND((SUM(dwr.work_hours) / (
|
||||||
|
SELECT SUM(work_hours)
|
||||||
|
FROM daily_work_reports
|
||||||
|
WHERE worker_id = dwr.worker_id
|
||||||
|
AND report_date BETWEEN ? AND ?
|
||||||
|
)) * 100, 2) as percentage
|
||||||
|
FROM daily_work_reports dwr
|
||||||
|
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
|
||||||
|
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||||
|
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||||
|
WHERE dwr.report_date BETWEEN ? AND ?
|
||||||
|
GROUP BY dwr.worker_id, w.worker_name, dwr.work_type_id, wt.name, dwr.project_id, p.project_name
|
||||||
|
HAVING totalHours > 0
|
||||||
|
ORDER BY dwr.worker_id, totalHours DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await this.db.execute(query, [startDate, endDate, startDate, endDate]);
|
||||||
|
return results.map(row => ({
|
||||||
|
worker_id: row.worker_id,
|
||||||
|
worker_name: row.worker_name || `작업자 ${row.worker_id}`,
|
||||||
|
work_type_id: row.work_type_id,
|
||||||
|
work_type_name: row.work_type_name || `작업유형 ${row.work_type_id}`,
|
||||||
|
project_id: row.project_id,
|
||||||
|
project_name: row.project_name || `프로젝트 ${row.project_id}`,
|
||||||
|
totalHours: parseFloat(row.totalHours) || 0,
|
||||||
|
totalReports: parseInt(row.totalReports) || 0,
|
||||||
|
percentage: parseFloat(row.percentage) || 0
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`작업자별 전문분야 분석 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대시보드용 종합 데이터
|
||||||
|
async getDashboardData(startDate, endDate) {
|
||||||
|
try {
|
||||||
|
// 병렬로 모든 데이터 조회
|
||||||
|
const [
|
||||||
|
stats,
|
||||||
|
dailyTrend,
|
||||||
|
workerStats,
|
||||||
|
projectStats,
|
||||||
|
workTypeStats,
|
||||||
|
recentWork
|
||||||
|
] = await Promise.all([
|
||||||
|
this.getBasicStats(startDate, endDate),
|
||||||
|
this.getDailyTrend(startDate, endDate),
|
||||||
|
this.getWorkerStats(startDate, endDate),
|
||||||
|
this.getProjectStats(startDate, endDate),
|
||||||
|
this.getWorkTypeStats(startDate, endDate),
|
||||||
|
this.getRecentWork(startDate, endDate, 20)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats,
|
||||||
|
dailyTrend,
|
||||||
|
workerStats,
|
||||||
|
projectStats,
|
||||||
|
workTypeStats,
|
||||||
|
recentWork,
|
||||||
|
metadata: {
|
||||||
|
period: `${startDate} ~ ${endDate}`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`대시보드 데이터 조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = WorkAnalysis;
|
||||||
284
synology_deployment/api/utils/cache.js
Normal file
284
synology_deployment/api/utils/cache.js
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// utils/cache.js - 통합 캐싱 시스템
|
||||||
|
|
||||||
|
const NodeCache = require('node-cache');
|
||||||
|
|
||||||
|
// 메모리 캐시 (Redis가 없을 때 fallback)
|
||||||
|
const memoryCache = new NodeCache({
|
||||||
|
stdTTL: 600, // 기본 10분
|
||||||
|
checkperiod: 120, // 2분마다 만료된 키 정리
|
||||||
|
useClones: false // 성능 향상을 위해 복사본 생성 안함
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redis 클라이언트 (선택적)
|
||||||
|
let redisClient = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 연결 초기화 (선택적)
|
||||||
|
*/
|
||||||
|
const initRedis = async () => {
|
||||||
|
try {
|
||||||
|
const redis = require('redis');
|
||||||
|
|
||||||
|
redisClient = redis.createClient({
|
||||||
|
socket: {
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: process.env.REDIS_PORT || 6379,
|
||||||
|
reconnectStrategy: (retries) => {
|
||||||
|
if (retries > 10) {
|
||||||
|
console.warn('Redis 재시도 횟수 초과. 메모리 캐시를 사용합니다.');
|
||||||
|
return false; // Redis 연결 포기
|
||||||
|
}
|
||||||
|
return Math.min(retries * 100, 3000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
|
database: process.env.REDIS_DB || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on('error', (err) => {
|
||||||
|
console.warn('Redis 오류:', err.message);
|
||||||
|
redisClient = null; // Redis 사용 중단, 메모리 캐시로 fallback
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on('connect', () => {
|
||||||
|
console.log('✅ Redis 캐시 연결 성공');
|
||||||
|
});
|
||||||
|
|
||||||
|
await redisClient.connect();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Redis 초기화 실패, 메모리 캐시 사용:', error.message);
|
||||||
|
redisClient = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시에서 값 조회
|
||||||
|
*/
|
||||||
|
const get = async (key) => {
|
||||||
|
try {
|
||||||
|
if (redisClient && redisClient.isOpen) {
|
||||||
|
const value = await redisClient.get(key);
|
||||||
|
return value ? JSON.parse(value) : null;
|
||||||
|
} else {
|
||||||
|
return memoryCache.get(key) || null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`캐시 조회 오류 (${key}):`, error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시에 값 저장
|
||||||
|
*/
|
||||||
|
const set = async (key, value, ttl = 600) => {
|
||||||
|
try {
|
||||||
|
if (redisClient && redisClient.isOpen) {
|
||||||
|
await redisClient.setEx(key, ttl, JSON.stringify(value));
|
||||||
|
} else {
|
||||||
|
memoryCache.set(key, value, ttl);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`캐시 저장 오류 (${key}):`, error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시에서 값 삭제
|
||||||
|
*/
|
||||||
|
const del = async (key) => {
|
||||||
|
try {
|
||||||
|
if (redisClient && redisClient.isOpen) {
|
||||||
|
await redisClient.del(key);
|
||||||
|
} else {
|
||||||
|
memoryCache.del(key);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`캐시 삭제 오류 (${key}):`, error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 패턴으로 캐시 키 삭제
|
||||||
|
*/
|
||||||
|
const delPattern = async (pattern) => {
|
||||||
|
try {
|
||||||
|
if (redisClient && redisClient.isOpen) {
|
||||||
|
const keys = await redisClient.keys(pattern);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await redisClient.del(keys);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const keys = memoryCache.keys();
|
||||||
|
const matchingKeys = keys.filter(key => {
|
||||||
|
const regex = new RegExp(pattern.replace('*', '.*'));
|
||||||
|
return regex.test(key);
|
||||||
|
});
|
||||||
|
memoryCache.del(matchingKeys);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`패턴 캐시 삭제 오류 (${pattern}):`, error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 캐시 초기화
|
||||||
|
*/
|
||||||
|
const flush = async () => {
|
||||||
|
try {
|
||||||
|
if (redisClient && redisClient.isOpen) {
|
||||||
|
await redisClient.flushDb();
|
||||||
|
} else {
|
||||||
|
memoryCache.flushAll();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('캐시 초기화 오류:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 통계 조회
|
||||||
|
*/
|
||||||
|
const getStats = () => {
|
||||||
|
if (redisClient && redisClient.isOpen) {
|
||||||
|
return {
|
||||||
|
type: 'redis',
|
||||||
|
connected: true,
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: process.env.REDIS_PORT || 6379
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const stats = memoryCache.getStats();
|
||||||
|
return {
|
||||||
|
type: 'memory',
|
||||||
|
connected: true,
|
||||||
|
keys: stats.keys,
|
||||||
|
hits: stats.hits,
|
||||||
|
misses: stats.misses,
|
||||||
|
hitRate: stats.hits / (stats.hits + stats.misses) || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 키 생성 헬퍼
|
||||||
|
*/
|
||||||
|
const createKey = (prefix, ...parts) => {
|
||||||
|
return `${prefix}:${parts.join(':')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TTL 상수 정의
|
||||||
|
*/
|
||||||
|
const TTL = {
|
||||||
|
SHORT: 60, // 1분
|
||||||
|
MEDIUM: 300, // 5분
|
||||||
|
LONG: 600, // 10분
|
||||||
|
HOUR: 3600, // 1시간
|
||||||
|
DAY: 86400 // 24시간
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 미들웨어 생성기
|
||||||
|
*/
|
||||||
|
const createCacheMiddleware = (keyGenerator, ttl = TTL.MEDIUM) => {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const cacheKey = typeof keyGenerator === 'function'
|
||||||
|
? keyGenerator(req)
|
||||||
|
: keyGenerator;
|
||||||
|
|
||||||
|
const cachedData = await get(cacheKey);
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
console.log(`🎯 캐시 히트: ${cacheKey}`);
|
||||||
|
return res.json(cachedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원본 res.json을 저장
|
||||||
|
const originalJson = res.json;
|
||||||
|
|
||||||
|
// res.json을 오버라이드하여 응답을 캐시에 저장
|
||||||
|
res.json = function(data) {
|
||||||
|
// 성공 응답만 캐시
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
set(cacheKey, data, ttl).then(() => {
|
||||||
|
console.log(`💾 캐시 저장: ${cacheKey}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원본 응답 실행
|
||||||
|
return originalJson.call(this, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('캐시 미들웨어 오류:', error.message);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 무효화 헬퍼
|
||||||
|
*/
|
||||||
|
const invalidateCache = {
|
||||||
|
// 사용자 관련 캐시 무효화
|
||||||
|
user: async (userId) => {
|
||||||
|
await delPattern(`user:${userId}:*`);
|
||||||
|
await delPattern('users:*');
|
||||||
|
},
|
||||||
|
|
||||||
|
// 작업자 관련 캐시 무효화
|
||||||
|
worker: async (workerId) => {
|
||||||
|
await delPattern(`worker:${workerId}:*`);
|
||||||
|
await delPattern('workers:*');
|
||||||
|
},
|
||||||
|
|
||||||
|
// 프로젝트 관련 캐시 무효화
|
||||||
|
project: async (projectId) => {
|
||||||
|
await delPattern(`project:${projectId}:*`);
|
||||||
|
await delPattern('projects:*');
|
||||||
|
},
|
||||||
|
|
||||||
|
// 작업 관련 캐시 무효화
|
||||||
|
task: async (taskId) => {
|
||||||
|
await delPattern(`task:${taskId}:*`);
|
||||||
|
await delPattern('tasks:*');
|
||||||
|
},
|
||||||
|
|
||||||
|
// 일일 작업 보고서 관련 캐시 무효화
|
||||||
|
dailyWorkReport: async (date) => {
|
||||||
|
await delPattern(`daily-work-report:${date}:*`);
|
||||||
|
await delPattern('daily-work-reports:*');
|
||||||
|
},
|
||||||
|
|
||||||
|
// 전체 캐시 무효화
|
||||||
|
all: async () => {
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initRedis,
|
||||||
|
get,
|
||||||
|
set,
|
||||||
|
del,
|
||||||
|
delPattern,
|
||||||
|
flush,
|
||||||
|
getStats,
|
||||||
|
createKey,
|
||||||
|
TTL,
|
||||||
|
createCacheMiddleware,
|
||||||
|
invalidateCache
|
||||||
|
};
|
||||||
169
synology_deployment/docker-compose.yml
Normal file
169
synology_deployment/docker-compose.yml
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# 시놀로지 NAS 923+ 배포용 Docker Compose
|
||||||
|
# TK-FB-Project - 테크니컬코리아 작업 관리 시스템
|
||||||
|
|
||||||
|
services:
|
||||||
|
# MySQL 데이터베이스
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: tkfb_db_prod
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: tkfb2024!
|
||||||
|
MYSQL_DATABASE: hyungi
|
||||||
|
MYSQL_USER: hyungi_user
|
||||||
|
MYSQL_PASSWORD: hyungi_pass2024!
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
- ./init_db:/docker-entrypoint-initdb.d
|
||||||
|
- ./mysql_conf:/etc/mysql/conf.d
|
||||||
|
command: --default-authentication-plugin=mysql_native_password
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-ptkfb2024!"]
|
||||||
|
timeout: 20s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- tkfb_network
|
||||||
|
|
||||||
|
# Redis 캐시 서버
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: tkfb_redis_prod
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- tkfb_network
|
||||||
|
|
||||||
|
# Node.js API 서버
|
||||||
|
api:
|
||||||
|
build: ./api
|
||||||
|
container_name: tkfb_api_prod
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_NAME: hyungi
|
||||||
|
DB_USER: hyungi_user
|
||||||
|
DB_PASSWORD: hyungi_pass2024!
|
||||||
|
JWT_SECRET: tkfb_jwt_secret_2024_synology_prod
|
||||||
|
PORT: 3005
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
ports:
|
||||||
|
- "20005:3005"
|
||||||
|
volumes:
|
||||||
|
- api_logs:/usr/src/app/logs
|
||||||
|
- api_uploads:/usr/src/app/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- tkfb_network
|
||||||
|
|
||||||
|
# FastAPI 브리지 서버
|
||||||
|
fastapi:
|
||||||
|
build: ./fastapi-bridge
|
||||||
|
container_name: tkfb_fastapi_prod
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PYTHONPATH: /app
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- fastapi_logs:/app/logs
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- tkfb_network
|
||||||
|
|
||||||
|
# Nginx 웹 서버
|
||||||
|
web:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: tkfb_web_prod
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "20000:80"
|
||||||
|
volumes:
|
||||||
|
- ./web-ui:/usr/share/nginx/html
|
||||||
|
- ./nginx_conf/nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
- ./nginx_conf/default.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
- fastapi
|
||||||
|
networks:
|
||||||
|
- tkfb_network
|
||||||
|
|
||||||
|
# phpMyAdmin (선택사항 - 개발/관리용)
|
||||||
|
phpmyadmin:
|
||||||
|
image: phpmyadmin/phpmyadmin
|
||||||
|
container_name: tkfb_phpmyadmin_prod
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PMA_HOST: db
|
||||||
|
PMA_PORT: 3306
|
||||||
|
PMA_USER: root
|
||||||
|
PMA_PASSWORD: tkfb2024!
|
||||||
|
MYSQL_ROOT_PASSWORD: tkfb2024!
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- tkfb_network
|
||||||
|
|
||||||
|
# 네트워크 설정 (단순화)
|
||||||
|
networks:
|
||||||
|
tkfb_network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
# 볼륨 설정 (시놀로지 SSD 최적화)
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: /volume2/docker/tkfb/mysql_data
|
||||||
|
api_logs:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: /volume2/docker/tkfb/api_logs
|
||||||
|
api_uploads:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: /volume2/docker/tkfb/api_uploads
|
||||||
|
fastapi_logs:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: /volume2/docker/tkfb/fastapi_logs
|
||||||
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: /volume2/docker/tkfb/redis_data
|
||||||
Reference in New Issue
Block a user