From 84cf222b81777512cc121f7871b9be45c0e2a46b Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 17 Mar 2026 15:56:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(tkuser):=20=EC=95=8C=EB=A6=BC=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=9D=B4=EA=B4=80=20system1-factory=20?= =?UTF-8?q?=E2=86=92=20tkuser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 1: tkuser에 알림 CRUD, Push/ntfy 발송, 내부 알림 API 추가 - Phase 2: notifyHelper URL을 tkuser-api:3000으로 전환 (system2, tkpurchase, tksafety, system1) - Phase 3: notification-bell.js API 도메인 tkuser로 변경 + 캐시 버스팅 v=4 - Phase 4: system1에서 알림 코드 제거 (routes, controllers, models, utils) Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 15 ++--- gateway/html/shared/notification-bell.js | 4 +- system1-factory/api/config/routes.js | 6 -- system1-factory/api/index.js | 17 ----- system1-factory/api/models/equipmentModel.js | 23 ++++--- system1-factory/api/models/purchaseModel.js | 22 +++---- system1-factory/api/package.json | 3 +- system1-factory/api/utils/notifyHelper.js | 63 +++++++++++++++++++ system1-factory/web/push-sw.js | 41 ------------ system1-factory/web/static/js/tkfb-core.js | 2 +- system2-report/api/utils/notifyHelper.js | 2 +- system2-report/web/js/api-base.js | 2 +- system3-nonconformance/web/static/js/app.js | 2 +- tkpurchase/api/utils/notifyHelper.js | 2 +- tkpurchase/web/static/js/tkpurchase-core.js | 2 +- tksafety/api/utils/notifyHelper.js | 2 +- tksafety/web/static/js/tksafety-core.js | 2 +- tksupport/web/static/js/tksupport-core.js | 2 +- .../api/controllers/notificationController.js | 0 .../controllers/pushSubscriptionController.js | 0 user-management/api/index.js | 23 +++++++ user-management/api/middleware/auth.js | 29 ++++++++- .../api/models/notificationModel.js | 57 +++++++---------- .../api/models/pushSubscriptionModel.js | 46 +++++++------- user-management/api/package.json | 4 +- .../api/routes/notificationRoutes.js | 2 +- .../api/routes/pushSubscriptionRoutes.js | 2 +- user-management/api/utils/cache.js | 39 ++++++++++++ .../api/utils/ntfySender.js | 0 user-management/web/static/js/tkuser-core.js | 2 +- 30 files changed, 244 insertions(+), 172 deletions(-) create mode 100644 system1-factory/api/utils/notifyHelper.js delete mode 100644 system1-factory/web/push-sw.js rename {system1-factory => user-management}/api/controllers/notificationController.js (100%) rename {system1-factory => user-management}/api/controllers/pushSubscriptionController.js (100%) rename {system1-factory => user-management}/api/models/notificationModel.js (85%) rename {system1-factory => user-management}/api/models/pushSubscriptionModel.js (62%) rename {system1-factory => user-management}/api/routes/notificationRoutes.js (94%) rename {system1-factory => user-management}/api/routes/pushSubscriptionRoutes.js (92%) create mode 100644 user-management/api/utils/cache.js rename {system1-factory => user-management}/api/utils/ntfySender.js (100%) diff --git a/docker-compose.yml b/docker-compose.yml index ee7fb1c..8f8d521 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,14 +103,7 @@ services: - REDIS_PORT=6379 - WEATHER_API_URL=${WEATHER_API_URL:-} - WEATHER_API_KEY=${WEATHER_API_KEY:-} - - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY} - - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} - - VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@technicalkorea.net} - INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY} - - NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80} - - NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN} - - NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net} - - TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net} volumes: - system1_uploads:/usr/src/app/uploads - system1_logs:/usr/src/app/logs @@ -274,6 +267,14 @@ services: - DB_PASSWORD=${MYSQL_PASSWORD} - DB_NAME=${MYSQL_DATABASE:-hyungi} - SSO_JWT_SECRET=${SSO_JWT_SECRET} + - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY} + - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} + - VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@technicalkorea.net} + - INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY} + - NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80} + - NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN} + - NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net} + - TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net} volumes: - system1_uploads:/usr/src/app/uploads depends_on: diff --git a/gateway/html/shared/notification-bell.js b/gateway/html/shared/notification-bell.js index 683fc99..d827718 100644 --- a/gateway/html/shared/notification-bell.js +++ b/gateway/html/shared/notification-bell.js @@ -16,8 +16,8 @@ var DROPDOWN_LIMIT = 5; var API_ORIGIN = (function () { var h = window.location.hostname; - if (h.includes('technicalkorea.net')) return 'https://tkfb.technicalkorea.net'; - return window.location.protocol + '//' + h + ':30005'; + if (h.includes('technicalkorea.net')) return 'https://tkuser.technicalkorea.net'; + return window.location.protocol + '//' + h + ':30300'; })(); var API_BASE = API_ORIGIN + '/api/notifications'; var PUSH_API_BASE = API_ORIGIN + '/api/push'; diff --git a/system1-factory/api/config/routes.js b/system1-factory/api/config/routes.js index 0f588e8..ac8a864 100644 --- a/system1-factory/api/config/routes.js +++ b/system1-factory/api/config/routes.js @@ -50,8 +50,6 @@ function setupRoutes(app) { const workIssueRoutes = require('../routes/workIssueRoutes'); const departmentRoutes = require('../routes/departmentRoutes'); const patrolRoutes = require('../routes/patrolRoutes'); - const notificationRoutes = require('../routes/notificationRoutes'); - const pushSubscriptionRoutes = require('../routes/pushSubscriptionRoutes'); const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes'); const purchaseRoutes = require('../routes/purchaseRoutes'); const settlementRoutes = require('../routes/settlementRoutes'); @@ -112,8 +110,6 @@ function setupRoutes(app) { '/api/setup/check-data-status', '/api/monthly-status/calendar', '/api/monthly-status/daily-details', - '/api/push/vapid-public-key', - '/api/notifications/internal' ]; // 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행) @@ -164,8 +160,6 @@ function setupRoutes(app) { app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유) app.use('/api/departments', departmentRoutes); // 부서 관리 app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템 - app.use('/api/notifications', notificationRoutes); // 알림 시스템 - app.use('/api/push', pushSubscriptionRoutes); // Push 구독 app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청 app.use('/api/purchases', purchaseRoutes); // 구매 내역 app.use('/api/settlements', settlementRoutes); // 월간 정산 diff --git a/system1-factory/api/index.js b/system1-factory/api/index.js index 8c8646a..186d254 100644 --- a/system1-factory/api/index.js +++ b/system1-factory/api/index.js @@ -120,21 +120,4 @@ process.on('uncaughtException', (error) => { } })(); -// 오래된 알림 정리 cron (매일 03:00 KST) -(function scheduleNotificationCleanup() { - const notificationModel = require('./models/notificationModel'); - function runCleanup() { - const now = new Date(); - const kstHour = (now.getUTCHours() + 9) % 24; - if (kstHour === 3 && now.getMinutes() < 1) { - notificationModel.deleteOld(30).then(count => { - if (count > 0) logger.info(`오래된 알림 ${count}건 정리 완료`); - }).catch(err => { - logger.error('알림 정리 실패:', { error: err.message }); - }); - } - } - setInterval(runCleanup, 60000); // 1분마다 확인 -})(); - module.exports = app; diff --git a/system1-factory/api/models/equipmentModel.js b/system1-factory/api/models/equipmentModel.js index 0cd3a2d..12945b1 100644 --- a/system1-factory/api/models/equipmentModel.js +++ b/system1-factory/api/models/equipmentModel.js @@ -1,6 +1,6 @@ // models/equipmentModel.js const { getDb } = require('../dbPool'); -const notificationModel = require('./notificationModel'); +const notifyHelper = require('../utils/notifyHelper'); const EquipmentModel = { // CREATE - 설비 생성 @@ -669,17 +669,16 @@ const EquipmentModel = { ['repair_needed', requestData.equipment_id] ); - try { - await notificationModel.createRepairNotification({ - equipment_id: requestData.equipment_id, - equipment_name: requestData.equipment_name || '설비', - repair_type: requestData.repair_type || '일반 수리', - request_id: result.insertId, - created_by: requestData.reported_by - }); - } catch (notifError) { - // 알림 생성 실패해도 수리 신청은 성공으로 처리 - } + // fire-and-forget: 알림 실패가 수리 신청을 블로킹하면 안 됨 + notifyHelper.send({ + type: 'repair', + title: `수리 신청: ${requestData.equipment_name || '설비'}`, + message: `${requestData.repair_type || '일반 수리'} 수리가 신청되었습니다.`, + link_url: '/pages/admin/repair-management.html', + reference_type: 'work_issue_reports', + reference_id: result.insertId, + created_by: requestData.reported_by + }).catch(() => {}); return { report_id: result.insertId, diff --git a/system1-factory/api/models/purchaseModel.js b/system1-factory/api/models/purchaseModel.js index aa2f8a6..afb845a 100644 --- a/system1-factory/api/models/purchaseModel.js +++ b/system1-factory/api/models/purchaseModel.js @@ -92,19 +92,15 @@ const PurchaseModel = { } catch (err) { console.error('[purchase] 설비 자동 등록 실패:', err.message); - // admin 알림 전송 - try { - const notificationModel = require('./notificationModel'); - await notificationModel.createTypedNotification({ - type: 'equipment', - title: `설비 자동 등록 실패: ${purchaseData.item_name}`, - message: `구매 완료 후 설비 자동 등록에 실패했습니다. 수동으로 등록해주세요. 오류: ${err.message}`, - link_url: '/pages/admin/equipments.html', - created_by: purchaseData.purchaser_id - }); - } catch (notifErr) { - console.error('[purchase] 설비 등록 실패 알림 전송 오류:', notifErr.message); - } + // fire-and-forget: admin 알림 전송 + const notifyHelper = require('../utils/notifyHelper'); + notifyHelper.send({ + type: 'equipment', + title: `설비 자동 등록 실패: ${purchaseData.item_name}`, + message: `구매 완료 후 설비 자동 등록에 실패했습니다. 수동으로 등록해주세요. 오류: ${err.message}`, + link_url: '/pages/admin/equipments.html', + created_by: purchaseData.purchaser_id + }).catch(() => {}); return { success: false, error: err.message }; } diff --git a/system1-factory/api/package.json b/system1-factory/api/package.json index ce76d6f..efe43f1 100644 --- a/system1-factory/api/package.json +++ b/system1-factory/api/package.json @@ -39,8 +39,7 @@ "qrcode": "^1.5.4", "redis": "^5.9.0", "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1", - "web-push": "^3.6.7" + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@types/jest": "^29.5.12", diff --git a/system1-factory/api/utils/notifyHelper.js b/system1-factory/api/utils/notifyHelper.js new file mode 100644 index 0000000..7708a9c --- /dev/null +++ b/system1-factory/api/utils/notifyHelper.js @@ -0,0 +1,63 @@ +// utils/notifyHelper.js — 공용 알림 헬퍼 +// tkuser-api의 내부 알림 API를 통해 DB 저장 + Push 전송 +const http = require('http'); + +const NOTIFY_URL = 'http://tkuser-api:3000/api/notifications/internal'; +const SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || ''; + +const notifyHelper = { + /** + * 알림 전송 + * @param {Object} opts + * @param {string} opts.type - 알림 유형 (safety, maintenance, repair, system) + * @param {string} opts.title - 알림 제목 + * @param {string} [opts.message] - 알림 내용 + * @param {string} [opts.link_url] - 클릭 시 이동 URL + * @param {string} [opts.reference_type] - 연관 테이블명 + * @param {number} [opts.reference_id] - 연관 레코드 ID + * @param {number} [opts.created_by] - 생성자 user_id + */ + async send(opts) { + try { + const body = JSON.stringify(opts); + const url = new URL(NOTIFY_URL); + + return new Promise((resolve) => { + const req = http.request({ + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Internal-Service-Key': SERVICE_KEY, + 'Content-Length': Buffer.byteLength(body) + }, + timeout: 5000 + }, (res) => { + res.resume(); // drain + resolve(true); + }); + + req.on('error', (err) => { + console.error('[notifyHelper] 알림 전송 실패:', err.message); + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + console.error('[notifyHelper] 알림 전송 타임아웃'); + resolve(false); + }); + + req.write(body); + req.end(); + }); + } catch (err) { + console.error('[notifyHelper] 알림 전송 오류:', err.message); + return false; + } + } +}; + +module.exports = notifyHelper; diff --git a/system1-factory/web/push-sw.js b/system1-factory/web/push-sw.js deleted file mode 100644 index 930acbc..0000000 --- a/system1-factory/web/push-sw.js +++ /dev/null @@ -1,41 +0,0 @@ -// Push Notification Service Worker -// 캐싱 없음 — Push 수신 전용 - -self.addEventListener('push', function(event) { - var data = { title: '알림', body: '새 알림이 있습니다.', url: '/' }; - if (event.data) { - try { data = Object.assign(data, event.data.json()); } catch(e) {} - } - event.waitUntil( - self.registration.showNotification(data.title, { - body: data.body, - icon: '/static/img/icon-192.png', - badge: '/static/img/badge-72.png', - data: { url: data.url || '/' }, - tag: 'tk-notification-' + Date.now(), - renotify: true - }) - ); - // 메인 페이지에 뱃지 갱신 신호 전송 - self.clients.matchAll({ type: 'window' }).then(function(clients) { - clients.forEach(function(client) { - client.postMessage({ type: 'NOTIFICATION_RECEIVED' }); - }); - }); -}); - -self.addEventListener('notificationclick', function(event) { - event.notification.close(); - var url = (event.notification.data && event.notification.data.url) || '/'; - event.waitUntil( - self.clients.matchAll({ type: 'window' }).then(function(clients) { - for (var i = 0; i < clients.length; i++) { - if (clients[i].url.includes(self.location.origin)) { - clients[i].navigate(url); - return clients[i].focus(); - } - } - return self.clients.openWindow(url); - }) - ); -}); diff --git a/system1-factory/web/static/js/tkfb-core.js b/system1-factory/web/static/js/tkfb-core.js index 83d5110..3dcc2d1 100644 --- a/system1-factory/web/static/js/tkfb-core.js +++ b/system1-factory/web/static/js/tkfb-core.js @@ -299,7 +299,7 @@ async function initAuth() { /* ===== 알림 벨 ===== */ function _loadNotificationBell() { const s = document.createElement('script'); - s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=3'; + s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=4'; document.head.appendChild(s); } diff --git a/system2-report/api/utils/notifyHelper.js b/system2-report/api/utils/notifyHelper.js index 503e16f..e9eb0b2 100644 --- a/system2-report/api/utils/notifyHelper.js +++ b/system2-report/api/utils/notifyHelper.js @@ -2,7 +2,7 @@ // system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송 const http = require('http'); -const NOTIFY_URL = 'http://system1-api:3005/api/notifications/internal'; +const NOTIFY_URL = 'http://tkuser-api:3000/api/notifications/internal'; const SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || ''; const notifyHelper = { diff --git a/system2-report/web/js/api-base.js b/system2-report/web/js/api-base.js index ede779f..67e3698 100644 --- a/system2-report/web/js/api-base.js +++ b/system2-report/web/js/api-base.js @@ -153,7 +153,7 @@ if ('serviceWorker' in navigator) { window._loadNotificationBell = function() { var h = window.location.hostname; var s = document.createElement('script'); - s.src = (h.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=3'; + s.src = (h.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=4'; document.head.appendChild(s); }; diff --git a/system3-nonconformance/web/static/js/app.js b/system3-nonconformance/web/static/js/app.js index 3f06a9a..1e39206 100644 --- a/system3-nonconformance/web/static/js/app.js +++ b/system3-nonconformance/web/static/js/app.js @@ -401,7 +401,7 @@ class App { _loadNotificationBell() { var h = window.location.hostname; var s = document.createElement('script'); - s.src = (h.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=3'; + s.src = (h.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=4'; document.head.appendChild(s); } diff --git a/tkpurchase/api/utils/notifyHelper.js b/tkpurchase/api/utils/notifyHelper.js index 503e16f..e9eb0b2 100644 --- a/tkpurchase/api/utils/notifyHelper.js +++ b/tkpurchase/api/utils/notifyHelper.js @@ -2,7 +2,7 @@ // system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송 const http = require('http'); -const NOTIFY_URL = 'http://system1-api:3005/api/notifications/internal'; +const NOTIFY_URL = 'http://tkuser-api:3000/api/notifications/internal'; const SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || ''; const notifyHelper = { diff --git a/tkpurchase/web/static/js/tkpurchase-core.js b/tkpurchase/web/static/js/tkpurchase-core.js index 0de2c72..5210ce2 100644 --- a/tkpurchase/web/static/js/tkpurchase-core.js +++ b/tkpurchase/web/static/js/tkpurchase-core.js @@ -195,7 +195,7 @@ function initAuth() { /* ===== 알림 벨 ===== */ function _loadNotificationBell() { const s = document.createElement('script'); - s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=3'; + s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=4'; document.head.appendChild(s); } diff --git a/tksafety/api/utils/notifyHelper.js b/tksafety/api/utils/notifyHelper.js index 503e16f..e9eb0b2 100644 --- a/tksafety/api/utils/notifyHelper.js +++ b/tksafety/api/utils/notifyHelper.js @@ -2,7 +2,7 @@ // system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송 const http = require('http'); -const NOTIFY_URL = 'http://system1-api:3005/api/notifications/internal'; +const NOTIFY_URL = 'http://tkuser-api:3000/api/notifications/internal'; const SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || ''; const notifyHelper = { diff --git a/tksafety/web/static/js/tksafety-core.js b/tksafety/web/static/js/tksafety-core.js index 4c6b13d..d5cd167 100644 --- a/tksafety/web/static/js/tksafety-core.js +++ b/tksafety/web/static/js/tksafety-core.js @@ -173,7 +173,7 @@ function initAuth() { /* ===== 알림 벨 ===== */ function _loadNotificationBell() { const s = document.createElement('script'); - s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=3'; + s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=4'; document.head.appendChild(s); } diff --git a/tksupport/web/static/js/tksupport-core.js b/tksupport/web/static/js/tksupport-core.js index 4d30d23..f5110ae 100644 --- a/tksupport/web/static/js/tksupport-core.js +++ b/tksupport/web/static/js/tksupport-core.js @@ -164,7 +164,7 @@ function initAuth() { /* ===== 알림 벨 ===== */ function _loadNotificationBell() { const s = document.createElement('script'); - s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=3'; + s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=4'; document.head.appendChild(s); } diff --git a/system1-factory/api/controllers/notificationController.js b/user-management/api/controllers/notificationController.js similarity index 100% rename from system1-factory/api/controllers/notificationController.js rename to user-management/api/controllers/notificationController.js diff --git a/system1-factory/api/controllers/pushSubscriptionController.js b/user-management/api/controllers/pushSubscriptionController.js similarity index 100% rename from system1-factory/api/controllers/pushSubscriptionController.js rename to user-management/api/controllers/pushSubscriptionController.js diff --git a/user-management/api/index.js b/user-management/api/index.js index f9ee2e9..e88af8c 100644 --- a/user-management/api/index.js +++ b/user-management/api/index.js @@ -21,6 +21,8 @@ const partnerRoutes = require('./routes/partnerRoutes'); const vendorRoutes = require('./routes/vendorRoutes'); const consumableItemRoutes = require('./routes/consumableItemRoutes'); const notificationRecipientRoutes = require('./routes/notificationRecipientRoutes'); +const notificationRoutes = require('./routes/notificationRoutes'); +const pushSubscriptionRoutes = require('./routes/pushSubscriptionRoutes'); const app = express(); const PORT = process.env.PORT || 3000; @@ -31,6 +33,8 @@ const allowedOrigins = [ 'https://tkqc.technicalkorea.net', 'https://tkuser.technicalkorea.net', 'https://tkpurchase.technicalkorea.net', + 'https://tksafety.technicalkorea.net', + 'https://tksupport.technicalkorea.net', ]; if (process.env.NODE_ENV === 'development') { allowedOrigins.push('http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280'); @@ -64,6 +68,8 @@ app.use('/api/partners', partnerRoutes); app.use('/api/vendors', vendorRoutes); app.use('/api/consumable-items', consumableItemRoutes); app.use('/api/notification-recipients', notificationRecipientRoutes); +app.use('/api/notifications', notificationRoutes); +app.use('/api/push', pushSubscriptionRoutes); // 404 app.use((req, res) => { @@ -83,4 +89,21 @@ app.listen(PORT, () => { console.log(`tkuser-api running on port ${PORT}`); }); +// 오래된 알림 정리 cron (매일 03:00 KST) +(function scheduleNotificationCleanup() { + const notificationModel = require('./models/notificationModel'); + function runCleanup() { + const now = new Date(); + const kstHour = (now.getUTCHours() + 9) % 24; + if (kstHour === 3 && now.getMinutes() < 1) { + notificationModel.deleteOld(30).then(count => { + if (count > 0) console.log(`오래된 알림 ${count}건 정리 완료`); + }).catch(err => { + console.error('알림 정리 실패:', err.message); + }); + } + } + setInterval(runCleanup, 60000); +})(); + module.exports = app; diff --git a/user-management/api/middleware/auth.js b/user-management/api/middleware/auth.js index 4b7b512..848a3fc 100644 --- a/user-management/api/middleware/auth.js +++ b/user-management/api/middleware/auth.js @@ -76,4 +76,31 @@ function requireAdminOrPermission(pageName) { }; } -module.exports = { extractToken, requireAuth, requireAdmin, requireAdminOrPermission }; +/** + * 최소 권한 레벨 체크 미들웨어 + * worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5) + */ +const ACCESS_LEVELS = { worker: 1, group_leader: 2, support_team: 3, admin: 4, system: 5 }; + +function requireMinLevel(minLevel) { + return (req, res, next) => { + const token = extractToken(req); + if (!token) { + return res.status(401).json({ success: false, error: '인증이 필요합니다' }); + } + try { + const decoded = jwt.verify(token, JWT_SECRET); + req.user = decoded; + const userLevel = ACCESS_LEVELS[decoded.access_level] || ACCESS_LEVELS[decoded.role] || 0; + const requiredLevel = ACCESS_LEVELS[minLevel] || 999; + if (userLevel < requiredLevel) { + return res.status(403).json({ success: false, error: `${minLevel} 이상의 권한이 필요합니다` }); + } + next(); + } catch { + return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' }); + } + }; +} + +module.exports = { extractToken, requireAuth, requireAdmin, requireAdminOrPermission, requireMinLevel }; diff --git a/system1-factory/api/models/notificationModel.js b/user-management/api/models/notificationModel.js similarity index 85% rename from system1-factory/api/models/notificationModel.js rename to user-management/api/models/notificationModel.js index f8a4716..92527c7 100644 --- a/system1-factory/api/models/notificationModel.js +++ b/user-management/api/models/notificationModel.js @@ -1,5 +1,5 @@ // models/notificationModel.js -const { getDb } = require('../dbPool'); +const { getPool } = require('./userModel'); // Web Push (lazy init) let webpush = null; @@ -79,10 +79,9 @@ async function sendPushToUsers(userIds, payload) { } } -// 순환 참조를 피하기 위해 함수 내에서 require async function getRecipientIds(notificationType) { - const db = await getDb(); - const [rows] = await db.query( + const pool = getPool(); + const [rows] = await pool.query( `SELECT user_id FROM notification_recipients WHERE notification_type = ? AND is_active = 1`, [notificationType] @@ -93,16 +92,15 @@ async function getRecipientIds(notificationType) { const notificationModel = { // 알림 생성 async create(notificationData) { - const db = await getDb(); + const pool = getPool(); const { user_id, type, title, message, link_url, reference_type, reference_id, created_by } = notificationData; - const [result] = await db.query( + const [result] = await pool.query( `INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [user_id || null, type || 'system', title, message || null, link_url || null, reference_type || null, reference_id || null, created_by || null] ); - // Redis 캐시 무효화 this._invalidateCache(user_id); return result.insertId; @@ -110,8 +108,8 @@ const notificationModel = { // 읽지 않은 알림 조회 (특정 사용자 또는 전체) async getUnread(userId = null) { - const db = await getDb(); - const [rows] = await db.query( + const pool = getPool(); + const [rows] = await pool.query( `SELECT * FROM notifications WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?) @@ -124,10 +122,10 @@ const notificationModel = { // 전체 알림 조회 (페이징) async getAll(userId = null, page = 1, limit = 20) { - const db = await getDb(); + const pool = getPool(); const offset = (page - 1) * limit; - const [rows] = await db.query( + const [rows] = await pool.query( `SELECT * FROM notifications WHERE (user_id IS NULL OR user_id = ?) ORDER BY created_at DESC @@ -135,7 +133,7 @@ const notificationModel = { [userId || 0, limit, offset] ); - const [[{ total }]] = await db.query( + const [[{ total }]] = await pool.query( `SELECT COUNT(*) as total FROM notifications WHERE (user_id IS NULL OR user_id = ?)`, [userId || 0] @@ -146,10 +144,9 @@ const notificationModel = { // 알림 읽음 처리 async markAsRead(notificationId) { - const db = await getDb(); - // 읽음 처리 전 user_id 조회 (캐시 무효화용) - const [[row]] = await db.query('SELECT user_id FROM notifications WHERE notification_id = ?', [notificationId]); - const [result] = await db.query( + const pool = getPool(); + const [[row]] = await pool.query('SELECT user_id FROM notifications WHERE notification_id = ?', [notificationId]); + const [result] = await pool.query( `UPDATE notifications SET is_read = 1, read_at = NOW() WHERE notification_id = ?`, [notificationId] ); @@ -159,8 +156,8 @@ const notificationModel = { // 모든 알림 읽음 처리 async markAllAsRead(userId = null) { - const db = await getDb(); - const [result] = await db.query( + const pool = getPool(); + const [result] = await pool.query( `UPDATE notifications SET is_read = 1, read_at = NOW() WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`, [userId || 0] @@ -171,8 +168,8 @@ const notificationModel = { // 알림 삭제 async delete(notificationId) { - const db = await getDb(); - const [result] = await db.query( + const pool = getPool(); + const [result] = await pool.query( `DELETE FROM notifications WHERE notification_id = ?`, [notificationId] ); @@ -181,8 +178,8 @@ const notificationModel = { // 오래된 알림 삭제 (30일 이상) async deleteOld(days = 30) { - const db = await getDb(); - const [result] = await db.query( + const pool = getPool(); + const [result] = await pool.query( `DELETE FROM notifications WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)`, [days] ); @@ -198,8 +195,8 @@ const notificationModel = { if (cached !== null && cached !== undefined) return cached; } catch (e) { /* 무시 */ } - const db = await getDb(); - const [[{ count }]] = await db.query( + const pool = getPool(); + const [[{ count }]] = await pool.query( `SELECT COUNT(*) as count FROM notifications WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`, [userId || 0] @@ -207,7 +204,7 @@ const notificationModel = { try { const cache = require('../utils/cache'); - await cache.set(cacheKey, count, 30); // TTL 30초 + await cache.set(cacheKey, count, 30); } catch (e) { /* 무시 */ } return count; @@ -226,11 +223,9 @@ const notificationModel = { async createRepairNotification(repairData) { const { equipment_id, equipment_name, repair_type, request_id, created_by } = repairData; - // 수리 알림 수신자 목록 가져오기 const recipientIds = await getRecipientIds('repair'); if (recipientIds.length === 0) { - // 수신자가 지정되지 않은 경우 전체 알림 (user_id = null) const id = await this.create({ type: 'repair', title: `수리 신청: ${equipment_name || '설비'}`, @@ -240,7 +235,6 @@ const notificationModel = { reference_id: request_id, created_by }); - // Push (broadcast) sendPushToUsers([], { title: `수리 신청: ${equipment_name || '설비'}`, body: `${repair_type} 수리가 신청되었습니다.`, @@ -249,7 +243,6 @@ const notificationModel = { return id; } - // 지정된 수신자 각각에게 알림 생성 const results = []; for (const userId of recipientIds) { const notificationId = await this.create({ @@ -265,7 +258,6 @@ const notificationModel = { results.push(notificationId); } - // Push 전송 sendPushToUsers(recipientIds, { title: `수리 신청: ${equipment_name || '설비'}`, body: `${repair_type} 수리가 신청되었습니다.`, @@ -279,11 +271,9 @@ const notificationModel = { async createTypedNotification(notificationData) { const { type, title, message, link_url, reference_type, reference_id, created_by } = notificationData; - // 해당 유형의 수신자 목록 가져오기 const recipientIds = await getRecipientIds(type); if (recipientIds.length === 0) { - // 수신자가 지정되지 않은 경우 전체 알림 const id = await this.create({ type, title, @@ -293,12 +283,10 @@ const notificationModel = { reference_id, created_by }); - // Push (broadcast) sendPushToUsers([], { title, body: message || '', url: link_url || '/' }); return id; } - // 지정된 수신자 각각에게 알림 생성 const results = []; for (const userId of recipientIds) { const notificationId = await this.create({ @@ -314,7 +302,6 @@ const notificationModel = { results.push(notificationId); } - // Push 전송 sendPushToUsers(recipientIds, { title, body: message || '', url: link_url || '/' }); return results; diff --git a/system1-factory/api/models/pushSubscriptionModel.js b/user-management/api/models/pushSubscriptionModel.js similarity index 62% rename from system1-factory/api/models/pushSubscriptionModel.js rename to user-management/api/models/pushSubscriptionModel.js index ce6a03d..e174795 100644 --- a/system1-factory/api/models/pushSubscriptionModel.js +++ b/user-management/api/models/pushSubscriptionModel.js @@ -1,11 +1,11 @@ // models/pushSubscriptionModel.js -const { getDb } = require('../dbPool'); +const { getPool } = require('./userModel'); const pushSubscriptionModel = { async subscribe(userId, subscription) { - const db = await getDb(); + const pool = getPool(); const { endpoint, keys } = subscription; - await db.query( + await pool.query( `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), p256dh = VALUES(p256dh), auth = VALUES(auth)`, @@ -14,13 +14,13 @@ const pushSubscriptionModel = { }, async unsubscribe(endpoint) { - const db = await getDb(); - await db.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]); + const pool = getPool(); + await pool.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]); }, async getByUserId(userId) { - const db = await getDb(); - const [rows] = await db.query( + const pool = getPool(); + const [rows] = await pool.query( 'SELECT * FROM push_subscriptions WHERE user_id = ?', [userId] ); @@ -29,8 +29,8 @@ const pushSubscriptionModel = { async getByUserIds(userIds) { if (!userIds || userIds.length === 0) return []; - const db = await getDb(); - const [rows] = await db.query( + const pool = getPool(); + const [rows] = await pool.query( 'SELECT * FROM push_subscriptions WHERE user_id IN (?)', [userIds] ); @@ -38,22 +38,22 @@ const pushSubscriptionModel = { }, async getAll() { - const db = await getDb(); - const [rows] = await db.query('SELECT * FROM push_subscriptions'); + const pool = getPool(); + const [rows] = await pool.query('SELECT * FROM push_subscriptions'); return rows; }, async deleteByEndpoint(endpoint) { - const db = await getDb(); - await db.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]); + const pool = getPool(); + await pool.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]); }, // === ntfy 구독 관련 === async getNtfyUserIds(userIds) { if (!userIds || userIds.length === 0) return []; - const db = await getDb(); - const [rows] = await db.query( + const pool = getPool(); + const [rows] = await pool.query( 'SELECT user_id FROM ntfy_subscriptions WHERE user_id IN (?)', [userIds] ); @@ -61,27 +61,27 @@ const pushSubscriptionModel = { }, async getAllNtfyUserIds() { - const db = await getDb(); - const [rows] = await db.query('SELECT user_id FROM ntfy_subscriptions'); + const pool = getPool(); + const [rows] = await pool.query('SELECT user_id FROM ntfy_subscriptions'); return rows.map(r => r.user_id); }, async ntfySubscribe(userId) { - const db = await getDb(); - await db.query( + const pool = getPool(); + await pool.query( 'INSERT IGNORE INTO ntfy_subscriptions (user_id) VALUES (?)', [userId] ); }, async ntfyUnsubscribe(userId) { - const db = await getDb(); - await db.query('DELETE FROM ntfy_subscriptions WHERE user_id = ?', [userId]); + const pool = getPool(); + await pool.query('DELETE FROM ntfy_subscriptions WHERE user_id = ?', [userId]); }, async isNtfySubscribed(userId) { - const db = await getDb(); - const [rows] = await db.query( + const pool = getPool(); + const [rows] = await pool.query( 'SELECT 1 FROM ntfy_subscriptions WHERE user_id = ? LIMIT 1', [userId] ); diff --git a/user-management/api/package.json b/user-management/api/package.json index 2c8a430..5534216 100644 --- a/user-management/api/package.json +++ b/user-management/api/package.json @@ -13,6 +13,8 @@ "express": "^4.18.2", "jsonwebtoken": "^9.0.0", "multer": "^1.4.5-lts.1", - "mysql2": "^3.14.1" + "mysql2": "^3.14.1", + "node-cache": "^5.1.2", + "web-push": "^3.6.7" } } diff --git a/system1-factory/api/routes/notificationRoutes.js b/user-management/api/routes/notificationRoutes.js similarity index 94% rename from system1-factory/api/routes/notificationRoutes.js rename to user-management/api/routes/notificationRoutes.js index 5637401..cc8f7f2 100644 --- a/system1-factory/api/routes/notificationRoutes.js +++ b/user-management/api/routes/notificationRoutes.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); const notificationController = require('../controllers/notificationController'); -const { requireAuth, requireMinLevel } = require('../middlewares/auth'); +const { requireAuth, requireMinLevel } = require('../middleware/auth'); // 내부 서비스용 알림 생성 (X-Internal-Service-Key 인증, JWT 불필요) router.post('/internal', notificationController.createInternal); diff --git a/system1-factory/api/routes/pushSubscriptionRoutes.js b/user-management/api/routes/pushSubscriptionRoutes.js similarity index 92% rename from system1-factory/api/routes/pushSubscriptionRoutes.js rename to user-management/api/routes/pushSubscriptionRoutes.js index 63da60c..c7021cd 100644 --- a/system1-factory/api/routes/pushSubscriptionRoutes.js +++ b/user-management/api/routes/pushSubscriptionRoutes.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); const pushController = require('../controllers/pushSubscriptionController'); -const { requireAuth } = require('../middlewares/auth'); +const { requireAuth } = require('../middleware/auth'); // VAPID 공개키 (인증 불필요) router.get('/vapid-public-key', pushController.getVapidPublicKey); diff --git a/user-management/api/utils/cache.js b/user-management/api/utils/cache.js new file mode 100644 index 0000000..56cc653 --- /dev/null +++ b/user-management/api/utils/cache.js @@ -0,0 +1,39 @@ +// utils/cache.js - NodeCache 기반 간소화 캐시 +const NodeCache = require('node-cache'); + +const memoryCache = new NodeCache({ + stdTTL: 600, + checkperiod: 120, + useClones: false +}); + +const get = async (key) => { + try { + return memoryCache.get(key) || null; + } catch (error) { + console.warn(`캐시 조회 오류 (${key}):`, error.message); + return null; + } +}; + +const set = async (key, value, ttl = 600) => { + try { + memoryCache.set(key, value, ttl); + return true; + } catch (error) { + console.warn(`캐시 저장 오류 (${key}):`, error.message); + return false; + } +}; + +const del = async (key) => { + try { + memoryCache.del(key); + return true; + } catch (error) { + console.warn(`캐시 삭제 오류 (${key}):`, error.message); + return false; + } +}; + +module.exports = { get, set, del }; diff --git a/system1-factory/api/utils/ntfySender.js b/user-management/api/utils/ntfySender.js similarity index 100% rename from system1-factory/api/utils/ntfySender.js rename to user-management/api/utils/ntfySender.js diff --git a/user-management/web/static/js/tkuser-core.js b/user-management/web/static/js/tkuser-core.js index 7feb4ff..5aaadf5 100644 --- a/user-management/web/static/js/tkuser-core.js +++ b/user-management/web/static/js/tkuser-core.js @@ -211,7 +211,7 @@ async function init() { /* ===== 알림 벨 ===== */ function _loadNotificationBell() { const s = document.createElement('script'); - s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=3'; + s.src = (location.hostname.includes('technicalkorea.net') ? 'https://tkds.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30000') + '/shared/notification-bell.js?v=4'; document.head.appendChild(s); }