From beaffcad499632cab80c4175eee1fe22ec695760 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 5 Nov 2025 15:18:01 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20MySQL=208.0=20=ED=98=B8=ED=99=98?= =?UTF-8?q?=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20-=20db.ex?= =?UTF-8?q?ecute=20=E2=86=92=20db.query=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ”§ μ£Όμš” 변경사항: - 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 μ°Έμ‘° --- MYSQL_COMPATIBILITY_NOTES.md | 81 ++++ .../api/models/WorkAnalysis.js | 430 ++++++++++++++++++ synology_deployment/api/utils/cache.js | 284 ++++++++++++ synology_deployment/docker-compose.yml | 169 +++++++ 4 files changed, 964 insertions(+) create mode 100644 MYSQL_COMPATIBILITY_NOTES.md create mode 100644 synology_deployment/api/models/WorkAnalysis.js create mode 100644 synology_deployment/api/utils/cache.js create mode 100644 synology_deployment/docker-compose.yml diff --git a/MYSQL_COMPATIBILITY_NOTES.md b/MYSQL_COMPATIBILITY_NOTES.md new file mode 100644 index 0000000..abd040b --- /dev/null +++ b/MYSQL_COMPATIBILITY_NOTES.md @@ -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 +**ν•΄κ²° μ™„λ£Œ**: βœ… +**ν…ŒμŠ€νŠΈ μ™„λ£Œ**: βœ… diff --git a/synology_deployment/api/models/WorkAnalysis.js b/synology_deployment/api/models/WorkAnalysis.js new file mode 100644 index 0000000..b61c7a4 --- /dev/null +++ b/synology_deployment/api/models/WorkAnalysis.js @@ -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; \ No newline at end of file diff --git a/synology_deployment/api/utils/cache.js b/synology_deployment/api/utils/cache.js new file mode 100644 index 0000000..de2002f --- /dev/null +++ b/synology_deployment/api/utils/cache.js @@ -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 +}; diff --git a/synology_deployment/docker-compose.yml b/synology_deployment/docker-compose.yml new file mode 100644 index 0000000..3aaa980 --- /dev/null +++ b/synology_deployment/docker-compose.yml @@ -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