feat(tkuser): 알림 시스템 이관 system1-factory → tkuser

- 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 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-17 15:56:41 +09:00
parent afa10c044f
commit 84cf222b81
30 changed files with 244 additions and 172 deletions

View File

@@ -103,14 +103,7 @@ services:
- REDIS_PORT=6379 - REDIS_PORT=6379
- WEATHER_API_URL=${WEATHER_API_URL:-} - WEATHER_API_URL=${WEATHER_API_URL:-}
- WEATHER_API_KEY=${WEATHER_API_KEY:-} - 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} - 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: volumes:
- system1_uploads:/usr/src/app/uploads - system1_uploads:/usr/src/app/uploads
- system1_logs:/usr/src/app/logs - system1_logs:/usr/src/app/logs
@@ -274,6 +267,14 @@ services:
- DB_PASSWORD=${MYSQL_PASSWORD} - DB_PASSWORD=${MYSQL_PASSWORD}
- DB_NAME=${MYSQL_DATABASE:-hyungi} - DB_NAME=${MYSQL_DATABASE:-hyungi}
- SSO_JWT_SECRET=${SSO_JWT_SECRET} - 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: volumes:
- system1_uploads:/usr/src/app/uploads - system1_uploads:/usr/src/app/uploads
depends_on: depends_on:

View File

@@ -16,8 +16,8 @@
var DROPDOWN_LIMIT = 5; var DROPDOWN_LIMIT = 5;
var API_ORIGIN = (function () { var API_ORIGIN = (function () {
var h = window.location.hostname; var h = window.location.hostname;
if (h.includes('technicalkorea.net')) return 'https://tkfb.technicalkorea.net'; if (h.includes('technicalkorea.net')) return 'https://tkuser.technicalkorea.net';
return window.location.protocol + '//' + h + ':30005'; return window.location.protocol + '//' + h + ':30300';
})(); })();
var API_BASE = API_ORIGIN + '/api/notifications'; var API_BASE = API_ORIGIN + '/api/notifications';
var PUSH_API_BASE = API_ORIGIN + '/api/push'; var PUSH_API_BASE = API_ORIGIN + '/api/push';

View File

@@ -50,8 +50,6 @@ function setupRoutes(app) {
const workIssueRoutes = require('../routes/workIssueRoutes'); const workIssueRoutes = require('../routes/workIssueRoutes');
const departmentRoutes = require('../routes/departmentRoutes'); const departmentRoutes = require('../routes/departmentRoutes');
const patrolRoutes = require('../routes/patrolRoutes'); const patrolRoutes = require('../routes/patrolRoutes');
const notificationRoutes = require('../routes/notificationRoutes');
const pushSubscriptionRoutes = require('../routes/pushSubscriptionRoutes');
const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes'); const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes');
const purchaseRoutes = require('../routes/purchaseRoutes'); const purchaseRoutes = require('../routes/purchaseRoutes');
const settlementRoutes = require('../routes/settlementRoutes'); const settlementRoutes = require('../routes/settlementRoutes');
@@ -112,8 +110,6 @@ function setupRoutes(app) {
'/api/setup/check-data-status', '/api/setup/check-data-status',
'/api/monthly-status/calendar', '/api/monthly-status/calendar',
'/api/monthly-status/daily-details', '/api/monthly-status/daily-details',
'/api/push/vapid-public-key',
'/api/notifications/internal'
]; ];
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행) // 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
@@ -164,8 +160,6 @@ function setupRoutes(app) {
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유) app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
app.use('/api/departments', departmentRoutes); // 부서 관리 app.use('/api/departments', departmentRoutes); // 부서 관리
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템 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/purchase-requests', purchaseRequestRoutes); // 구매신청
app.use('/api/purchases', purchaseRoutes); // 구매 내역 app.use('/api/purchases', purchaseRoutes); // 구매 내역
app.use('/api/settlements', settlementRoutes); // 월간 정산 app.use('/api/settlements', settlementRoutes); // 월간 정산

View File

@@ -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; module.exports = app;

View File

@@ -1,6 +1,6 @@
// models/equipmentModel.js // models/equipmentModel.js
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
const notificationModel = require('./notificationModel'); const notifyHelper = require('../utils/notifyHelper');
const EquipmentModel = { const EquipmentModel = {
// CREATE - 설비 생성 // CREATE - 설비 생성
@@ -669,17 +669,16 @@ const EquipmentModel = {
['repair_needed', requestData.equipment_id] ['repair_needed', requestData.equipment_id]
); );
try { // fire-and-forget: 알림 실패가 수리 신청을 블로킹하면 안 됨
await notificationModel.createRepairNotification({ notifyHelper.send({
equipment_id: requestData.equipment_id, type: 'repair',
equipment_name: requestData.equipment_name || '설비', title: `수리 신청: ${requestData.equipment_name || '설비'}`,
repair_type: requestData.repair_type || '일반 수리', message: `${requestData.repair_type || '일반 수리'} 수리가 신청되었습니다.`,
request_id: result.insertId, link_url: '/pages/admin/repair-management.html',
reference_type: 'work_issue_reports',
reference_id: result.insertId,
created_by: requestData.reported_by created_by: requestData.reported_by
}); }).catch(() => {});
} catch (notifError) {
// 알림 생성 실패해도 수리 신청은 성공으로 처리
}
return { return {
report_id: result.insertId, report_id: result.insertId,

View File

@@ -92,19 +92,15 @@ const PurchaseModel = {
} catch (err) { } catch (err) {
console.error('[purchase] 설비 자동 등록 실패:', err.message); console.error('[purchase] 설비 자동 등록 실패:', err.message);
// admin 알림 전송 // fire-and-forget: admin 알림 전송
try { const notifyHelper = require('../utils/notifyHelper');
const notificationModel = require('./notificationModel'); notifyHelper.send({
await notificationModel.createTypedNotification({
type: 'equipment', type: 'equipment',
title: `설비 자동 등록 실패: ${purchaseData.item_name}`, title: `설비 자동 등록 실패: ${purchaseData.item_name}`,
message: `구매 완료 후 설비 자동 등록에 실패했습니다. 수동으로 등록해주세요. 오류: ${err.message}`, message: `구매 완료 후 설비 자동 등록에 실패했습니다. 수동으로 등록해주세요. 오류: ${err.message}`,
link_url: '/pages/admin/equipments.html', link_url: '/pages/admin/equipments.html',
created_by: purchaseData.purchaser_id created_by: purchaseData.purchaser_id
}); }).catch(() => {});
} catch (notifErr) {
console.error('[purchase] 설비 등록 실패 알림 전송 오류:', notifErr.message);
}
return { success: false, error: err.message }; return { success: false, error: err.message };
} }

View File

@@ -39,8 +39,7 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"redis": "^5.9.0", "redis": "^5.9.0",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1"
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",

View File

@@ -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;

View File

@@ -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);
})
);
});

View File

@@ -299,7 +299,7 @@ async function initAuth() {
/* ===== 알림 벨 ===== */ /* ===== 알림 벨 ===== */
function _loadNotificationBell() { function _loadNotificationBell() {
const s = document.createElement('script'); 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); document.head.appendChild(s);
} }

View File

@@ -2,7 +2,7 @@
// system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송 // system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송
const http = require('http'); 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 SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || '';
const notifyHelper = { const notifyHelper = {

View File

@@ -153,7 +153,7 @@ if ('serviceWorker' in navigator) {
window._loadNotificationBell = function() { window._loadNotificationBell = function() {
var h = window.location.hostname; var h = window.location.hostname;
var s = document.createElement('script'); 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); document.head.appendChild(s);
}; };

View File

@@ -401,7 +401,7 @@ class App {
_loadNotificationBell() { _loadNotificationBell() {
var h = window.location.hostname; var h = window.location.hostname;
var s = document.createElement('script'); 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); document.head.appendChild(s);
} }

View File

@@ -2,7 +2,7 @@
// system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송 // system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송
const http = require('http'); 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 SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || '';
const notifyHelper = { const notifyHelper = {

View File

@@ -195,7 +195,7 @@ function initAuth() {
/* ===== 알림 벨 ===== */ /* ===== 알림 벨 ===== */
function _loadNotificationBell() { function _loadNotificationBell() {
const s = document.createElement('script'); 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); document.head.appendChild(s);
} }

View File

@@ -2,7 +2,7 @@
// system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송 // system1-factory의 내부 알림 API를 통해 DB 저장 + Push 전송
const http = require('http'); 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 SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || '';
const notifyHelper = { const notifyHelper = {

View File

@@ -173,7 +173,7 @@ function initAuth() {
/* ===== 알림 벨 ===== */ /* ===== 알림 벨 ===== */
function _loadNotificationBell() { function _loadNotificationBell() {
const s = document.createElement('script'); 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); document.head.appendChild(s);
} }

View File

@@ -164,7 +164,7 @@ function initAuth() {
/* ===== 알림 벨 ===== */ /* ===== 알림 벨 ===== */
function _loadNotificationBell() { function _loadNotificationBell() {
const s = document.createElement('script'); 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); document.head.appendChild(s);
} }

View File

@@ -21,6 +21,8 @@ const partnerRoutes = require('./routes/partnerRoutes');
const vendorRoutes = require('./routes/vendorRoutes'); const vendorRoutes = require('./routes/vendorRoutes');
const consumableItemRoutes = require('./routes/consumableItemRoutes'); const consumableItemRoutes = require('./routes/consumableItemRoutes');
const notificationRecipientRoutes = require('./routes/notificationRecipientRoutes'); const notificationRecipientRoutes = require('./routes/notificationRecipientRoutes');
const notificationRoutes = require('./routes/notificationRoutes');
const pushSubscriptionRoutes = require('./routes/pushSubscriptionRoutes');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -31,6 +33,8 @@ const allowedOrigins = [
'https://tkqc.technicalkorea.net', 'https://tkqc.technicalkorea.net',
'https://tkuser.technicalkorea.net', 'https://tkuser.technicalkorea.net',
'https://tkpurchase.technicalkorea.net', 'https://tkpurchase.technicalkorea.net',
'https://tksafety.technicalkorea.net',
'https://tksupport.technicalkorea.net',
]; ];
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
allowedOrigins.push('http://localhost:30080', 'http://localhost:30180', 'http://localhost:30280'); 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/vendors', vendorRoutes);
app.use('/api/consumable-items', consumableItemRoutes); app.use('/api/consumable-items', consumableItemRoutes);
app.use('/api/notification-recipients', notificationRecipientRoutes); app.use('/api/notification-recipients', notificationRecipientRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/push', pushSubscriptionRoutes);
// 404 // 404
app.use((req, res) => { app.use((req, res) => {
@@ -83,4 +89,21 @@ app.listen(PORT, () => {
console.log(`tkuser-api running on port ${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; module.exports = app;

View File

@@ -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 };

View File

@@ -1,5 +1,5 @@
// models/notificationModel.js // models/notificationModel.js
const { getDb } = require('../dbPool'); const { getPool } = require('./userModel');
// Web Push (lazy init) // Web Push (lazy init)
let webpush = null; let webpush = null;
@@ -79,10 +79,9 @@ async function sendPushToUsers(userIds, payload) {
} }
} }
// 순환 참조를 피하기 위해 함수 내에서 require
async function getRecipientIds(notificationType) { async function getRecipientIds(notificationType) {
const db = await getDb(); const pool = getPool();
const [rows] = await db.query( const [rows] = await pool.query(
`SELECT user_id FROM notification_recipients `SELECT user_id FROM notification_recipients
WHERE notification_type = ? AND is_active = 1`, WHERE notification_type = ? AND is_active = 1`,
[notificationType] [notificationType]
@@ -93,16 +92,15 @@ async function getRecipientIds(notificationType) {
const notificationModel = { const notificationModel = {
// 알림 생성 // 알림 생성
async create(notificationData) { 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 { 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) `INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[user_id || null, type || 'system', title, message || null, link_url || null, reference_type || null, reference_id || null, created_by || null] [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); this._invalidateCache(user_id);
return result.insertId; return result.insertId;
@@ -110,8 +108,8 @@ const notificationModel = {
// 읽지 않은 알림 조회 (특정 사용자 또는 전체) // 읽지 않은 알림 조회 (특정 사용자 또는 전체)
async getUnread(userId = null) { async getUnread(userId = null) {
const db = await getDb(); const pool = getPool();
const [rows] = await db.query( const [rows] = await pool.query(
`SELECT * FROM notifications `SELECT * FROM notifications
WHERE is_read = 0 WHERE is_read = 0
AND (user_id IS NULL OR user_id = ?) AND (user_id IS NULL OR user_id = ?)
@@ -124,10 +122,10 @@ const notificationModel = {
// 전체 알림 조회 (페이징) // 전체 알림 조회 (페이징)
async getAll(userId = null, page = 1, limit = 20) { async getAll(userId = null, page = 1, limit = 20) {
const db = await getDb(); const pool = getPool();
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const [rows] = await db.query( const [rows] = await pool.query(
`SELECT * FROM notifications `SELECT * FROM notifications
WHERE (user_id IS NULL OR user_id = ?) WHERE (user_id IS NULL OR user_id = ?)
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -135,7 +133,7 @@ const notificationModel = {
[userId || 0, limit, offset] [userId || 0, limit, offset]
); );
const [[{ total }]] = await db.query( const [[{ total }]] = await pool.query(
`SELECT COUNT(*) as total FROM notifications `SELECT COUNT(*) as total FROM notifications
WHERE (user_id IS NULL OR user_id = ?)`, WHERE (user_id IS NULL OR user_id = ?)`,
[userId || 0] [userId || 0]
@@ -146,10 +144,9 @@ const notificationModel = {
// 알림 읽음 처리 // 알림 읽음 처리
async markAsRead(notificationId) { async markAsRead(notificationId) {
const db = await getDb(); const pool = getPool();
// 읽음 처리 전 user_id 조회 (캐시 무효화용) const [[row]] = await pool.query('SELECT user_id FROM notifications WHERE notification_id = ?', [notificationId]);
const [[row]] = await db.query('SELECT user_id FROM notifications WHERE notification_id = ?', [notificationId]); const [result] = await pool.query(
const [result] = await db.query(
`UPDATE notifications SET is_read = 1, read_at = NOW() WHERE notification_id = ?`, `UPDATE notifications SET is_read = 1, read_at = NOW() WHERE notification_id = ?`,
[notificationId] [notificationId]
); );
@@ -159,8 +156,8 @@ const notificationModel = {
// 모든 알림 읽음 처리 // 모든 알림 읽음 처리
async markAllAsRead(userId = null) { async markAllAsRead(userId = null) {
const db = await getDb(); const pool = getPool();
const [result] = await db.query( const [result] = await pool.query(
`UPDATE notifications SET is_read = 1, read_at = NOW() `UPDATE notifications SET is_read = 1, read_at = NOW()
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`, WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0] [userId || 0]
@@ -171,8 +168,8 @@ const notificationModel = {
// 알림 삭제 // 알림 삭제
async delete(notificationId) { async delete(notificationId) {
const db = await getDb(); const pool = getPool();
const [result] = await db.query( const [result] = await pool.query(
`DELETE FROM notifications WHERE notification_id = ?`, `DELETE FROM notifications WHERE notification_id = ?`,
[notificationId] [notificationId]
); );
@@ -181,8 +178,8 @@ const notificationModel = {
// 오래된 알림 삭제 (30일 이상) // 오래된 알림 삭제 (30일 이상)
async deleteOld(days = 30) { async deleteOld(days = 30) {
const db = await getDb(); const pool = getPool();
const [result] = await db.query( const [result] = await pool.query(
`DELETE FROM notifications WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)`, `DELETE FROM notifications WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
[days] [days]
); );
@@ -198,8 +195,8 @@ const notificationModel = {
if (cached !== null && cached !== undefined) return cached; if (cached !== null && cached !== undefined) return cached;
} catch (e) { /* 무시 */ } } catch (e) { /* 무시 */ }
const db = await getDb(); const pool = getPool();
const [[{ count }]] = await db.query( const [[{ count }]] = await pool.query(
`SELECT COUNT(*) as count FROM notifications `SELECT COUNT(*) as count FROM notifications
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`, WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0] [userId || 0]
@@ -207,7 +204,7 @@ const notificationModel = {
try { try {
const cache = require('../utils/cache'); const cache = require('../utils/cache');
await cache.set(cacheKey, count, 30); // TTL 30초 await cache.set(cacheKey, count, 30);
} catch (e) { /* 무시 */ } } catch (e) { /* 무시 */ }
return count; return count;
@@ -226,11 +223,9 @@ const notificationModel = {
async createRepairNotification(repairData) { async createRepairNotification(repairData) {
const { equipment_id, equipment_name, repair_type, request_id, created_by } = repairData; const { equipment_id, equipment_name, repair_type, request_id, created_by } = repairData;
// 수리 알림 수신자 목록 가져오기
const recipientIds = await getRecipientIds('repair'); const recipientIds = await getRecipientIds('repair');
if (recipientIds.length === 0) { if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림 (user_id = null)
const id = await this.create({ const id = await this.create({
type: 'repair', type: 'repair',
title: `수리 신청: ${equipment_name || '설비'}`, title: `수리 신청: ${equipment_name || '설비'}`,
@@ -240,7 +235,6 @@ const notificationModel = {
reference_id: request_id, reference_id: request_id,
created_by created_by
}); });
// Push (broadcast)
sendPushToUsers([], { sendPushToUsers([], {
title: `수리 신청: ${equipment_name || '설비'}`, title: `수리 신청: ${equipment_name || '설비'}`,
body: `${repair_type} 수리가 신청되었습니다.`, body: `${repair_type} 수리가 신청되었습니다.`,
@@ -249,7 +243,6 @@ const notificationModel = {
return id; return id;
} }
// 지정된 수신자 각각에게 알림 생성
const results = []; const results = [];
for (const userId of recipientIds) { for (const userId of recipientIds) {
const notificationId = await this.create({ const notificationId = await this.create({
@@ -265,7 +258,6 @@ const notificationModel = {
results.push(notificationId); results.push(notificationId);
} }
// Push 전송
sendPushToUsers(recipientIds, { sendPushToUsers(recipientIds, {
title: `수리 신청: ${equipment_name || '설비'}`, title: `수리 신청: ${equipment_name || '설비'}`,
body: `${repair_type} 수리가 신청되었습니다.`, body: `${repair_type} 수리가 신청되었습니다.`,
@@ -279,11 +271,9 @@ const notificationModel = {
async createTypedNotification(notificationData) { async createTypedNotification(notificationData) {
const { type, title, message, link_url, reference_type, reference_id, created_by } = notificationData; const { type, title, message, link_url, reference_type, reference_id, created_by } = notificationData;
// 해당 유형의 수신자 목록 가져오기
const recipientIds = await getRecipientIds(type); const recipientIds = await getRecipientIds(type);
if (recipientIds.length === 0) { if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림
const id = await this.create({ const id = await this.create({
type, type,
title, title,
@@ -293,12 +283,10 @@ const notificationModel = {
reference_id, reference_id,
created_by created_by
}); });
// Push (broadcast)
sendPushToUsers([], { title, body: message || '', url: link_url || '/' }); sendPushToUsers([], { title, body: message || '', url: link_url || '/' });
return id; return id;
} }
// 지정된 수신자 각각에게 알림 생성
const results = []; const results = [];
for (const userId of recipientIds) { for (const userId of recipientIds) {
const notificationId = await this.create({ const notificationId = await this.create({
@@ -314,7 +302,6 @@ const notificationModel = {
results.push(notificationId); results.push(notificationId);
} }
// Push 전송
sendPushToUsers(recipientIds, { title, body: message || '', url: link_url || '/' }); sendPushToUsers(recipientIds, { title, body: message || '', url: link_url || '/' });
return results; return results;

View File

@@ -1,11 +1,11 @@
// models/pushSubscriptionModel.js // models/pushSubscriptionModel.js
const { getDb } = require('../dbPool'); const { getPool } = require('./userModel');
const pushSubscriptionModel = { const pushSubscriptionModel = {
async subscribe(userId, subscription) { async subscribe(userId, subscription) {
const db = await getDb(); const pool = getPool();
const { endpoint, keys } = subscription; const { endpoint, keys } = subscription;
await db.query( await pool.query(
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth) `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), p256dh = VALUES(p256dh), auth = VALUES(auth)`, ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), p256dh = VALUES(p256dh), auth = VALUES(auth)`,
@@ -14,13 +14,13 @@ const pushSubscriptionModel = {
}, },
async unsubscribe(endpoint) { async unsubscribe(endpoint) {
const db = await getDb(); const pool = getPool();
await db.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]); await pool.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]);
}, },
async getByUserId(userId) { async getByUserId(userId) {
const db = await getDb(); const pool = getPool();
const [rows] = await db.query( const [rows] = await pool.query(
'SELECT * FROM push_subscriptions WHERE user_id = ?', 'SELECT * FROM push_subscriptions WHERE user_id = ?',
[userId] [userId]
); );
@@ -29,8 +29,8 @@ const pushSubscriptionModel = {
async getByUserIds(userIds) { async getByUserIds(userIds) {
if (!userIds || userIds.length === 0) return []; if (!userIds || userIds.length === 0) return [];
const db = await getDb(); const pool = getPool();
const [rows] = await db.query( const [rows] = await pool.query(
'SELECT * FROM push_subscriptions WHERE user_id IN (?)', 'SELECT * FROM push_subscriptions WHERE user_id IN (?)',
[userIds] [userIds]
); );
@@ -38,22 +38,22 @@ const pushSubscriptionModel = {
}, },
async getAll() { async getAll() {
const db = await getDb(); const pool = getPool();
const [rows] = await db.query('SELECT * FROM push_subscriptions'); const [rows] = await pool.query('SELECT * FROM push_subscriptions');
return rows; return rows;
}, },
async deleteByEndpoint(endpoint) { async deleteByEndpoint(endpoint) {
const db = await getDb(); const pool = getPool();
await db.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]); await pool.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]);
}, },
// === ntfy 구독 관련 === // === ntfy 구독 관련 ===
async getNtfyUserIds(userIds) { async getNtfyUserIds(userIds) {
if (!userIds || userIds.length === 0) return []; if (!userIds || userIds.length === 0) return [];
const db = await getDb(); const pool = getPool();
const [rows] = await db.query( const [rows] = await pool.query(
'SELECT user_id FROM ntfy_subscriptions WHERE user_id IN (?)', 'SELECT user_id FROM ntfy_subscriptions WHERE user_id IN (?)',
[userIds] [userIds]
); );
@@ -61,27 +61,27 @@ const pushSubscriptionModel = {
}, },
async getAllNtfyUserIds() { async getAllNtfyUserIds() {
const db = await getDb(); const pool = getPool();
const [rows] = await db.query('SELECT user_id FROM ntfy_subscriptions'); const [rows] = await pool.query('SELECT user_id FROM ntfy_subscriptions');
return rows.map(r => r.user_id); return rows.map(r => r.user_id);
}, },
async ntfySubscribe(userId) { async ntfySubscribe(userId) {
const db = await getDb(); const pool = getPool();
await db.query( await pool.query(
'INSERT IGNORE INTO ntfy_subscriptions (user_id) VALUES (?)', 'INSERT IGNORE INTO ntfy_subscriptions (user_id) VALUES (?)',
[userId] [userId]
); );
}, },
async ntfyUnsubscribe(userId) { async ntfyUnsubscribe(userId) {
const db = await getDb(); const pool = getPool();
await db.query('DELETE FROM ntfy_subscriptions WHERE user_id = ?', [userId]); await pool.query('DELETE FROM ntfy_subscriptions WHERE user_id = ?', [userId]);
}, },
async isNtfySubscribed(userId) { async isNtfySubscribed(userId) {
const db = await getDb(); const pool = getPool();
const [rows] = await db.query( const [rows] = await pool.query(
'SELECT 1 FROM ntfy_subscriptions WHERE user_id = ? LIMIT 1', 'SELECT 1 FROM ntfy_subscriptions WHERE user_id = ? LIMIT 1',
[userId] [userId]
); );

View File

@@ -13,6 +13,8 @@
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.14.1" "mysql2": "^3.14.1",
"node-cache": "^5.1.2",
"web-push": "^3.6.7"
} }
} }

View File

@@ -2,7 +2,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const notificationController = require('../controllers/notificationController'); const notificationController = require('../controllers/notificationController');
const { requireAuth, requireMinLevel } = require('../middlewares/auth'); const { requireAuth, requireMinLevel } = require('../middleware/auth');
// 내부 서비스용 알림 생성 (X-Internal-Service-Key 인증, JWT 불필요) // 내부 서비스용 알림 생성 (X-Internal-Service-Key 인증, JWT 불필요)
router.post('/internal', notificationController.createInternal); router.post('/internal', notificationController.createInternal);

View File

@@ -2,7 +2,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const pushController = require('../controllers/pushSubscriptionController'); const pushController = require('../controllers/pushSubscriptionController');
const { requireAuth } = require('../middlewares/auth'); const { requireAuth } = require('../middleware/auth');
// VAPID 공개키 (인증 불필요) // VAPID 공개키 (인증 불필요)
router.get('/vapid-public-key', pushController.getVapidPublicKey); router.get('/vapid-public-key', pushController.getVapidPublicKey);

View File

@@ -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 };

View File

@@ -211,7 +211,7 @@ async function init() {
/* ===== 알림 벨 ===== */ /* ===== 알림 벨 ===== */
function _loadNotificationBell() { function _loadNotificationBell() {
const s = document.createElement('script'); 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); document.head.appendChild(s);
} }