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:
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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); // 월간 정산
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
63
system1-factory/api/utils/notifyHelper.js
Normal file
63
system1-factory/api/utils/notifyHelper.js
Normal 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;
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -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]
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
39
user-management/api/utils/cache.js
Normal file
39
user-management/api/utils/cache.js
Normal 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 };
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user