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
|
- 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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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); // 월간 정산
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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() {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
// 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;
|
||||||
@@ -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]
|
||||||
);
|
);
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
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() {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user