refactor: shared 모듈 추출 Phase 1~4 (notifyHelper, errors, logger, auth, dbPool)
Phase 1: notifyHelper.js → shared/utils/ (4개 서비스 중복 제거) Phase 2: auth.js → shared/middleware/ (system1/system2 통합) Phase 3: errors.js + logger.js → shared/utils/ (system1/system2 통합) Phase 4: DB pool → shared/config/database.js (Group B 4개 서비스 통합) - Docker 빌드 컨텍스트를 루트로 변경 (6개 API 서비스) - 기존 파일은 re-export 패턴으로 consumer 변경 0개 유지 - .dockerignore 추가로 빌드 최적화 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.claude
|
||||||
|
**/.env
|
||||||
|
**/node_modules
|
||||||
|
**/logs
|
||||||
|
**/__pycache__
|
||||||
|
**/.pytest_cache
|
||||||
|
**/venv
|
||||||
|
**/web/
|
||||||
|
*.md
|
||||||
|
!shared/**
|
||||||
|
FEATURES.pdf
|
||||||
@@ -80,8 +80,8 @@ services:
|
|||||||
|
|
||||||
system1-api:
|
system1-api:
|
||||||
build:
|
build:
|
||||||
context: ./system1-factory/api
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: system1-factory/api/Dockerfile
|
||||||
container_name: tk-system1-api
|
container_name: tk-system1-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -152,8 +152,8 @@ services:
|
|||||||
|
|
||||||
system2-api:
|
system2-api:
|
||||||
build:
|
build:
|
||||||
context: ./system2-report/api
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: system2-report/api/Dockerfile
|
||||||
container_name: tk-system2-api
|
container_name: tk-system2-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -252,8 +252,8 @@ services:
|
|||||||
|
|
||||||
tkuser-api:
|
tkuser-api:
|
||||||
build:
|
build:
|
||||||
context: ./user-management/api
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: user-management/api/Dockerfile
|
||||||
container_name: tk-tkuser-api
|
container_name: tk-tkuser-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -302,8 +302,8 @@ services:
|
|||||||
|
|
||||||
tkpurchase-api:
|
tkpurchase-api:
|
||||||
build:
|
build:
|
||||||
context: ./tkpurchase/api
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: tkpurchase/api/Dockerfile
|
||||||
container_name: tk-tkpurchase-api
|
container_name: tk-tkpurchase-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -343,8 +343,8 @@ services:
|
|||||||
|
|
||||||
tksafety-api:
|
tksafety-api:
|
||||||
build:
|
build:
|
||||||
context: ./tksafety/api
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: tksafety/api/Dockerfile
|
||||||
container_name: tk-tksafety-api
|
container_name: tk-tksafety-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -388,8 +388,8 @@ services:
|
|||||||
|
|
||||||
tksupport-api:
|
tksupport-api:
|
||||||
build:
|
build:
|
||||||
context: ./tksupport/api
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: tksupport/api/Dockerfile
|
||||||
container_name: tk-tksupport-api
|
container_name: tk-tksupport-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
28
shared/config/database.js
Normal file
28
shared/config/database.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 공유 데이터베이스 커넥션 풀
|
||||||
|
*
|
||||||
|
* Group B 서비스(tkuser, tkpurchase, tksafety, tksupport)용 동기 풀
|
||||||
|
* mysql2/promise 기반, 싱글톤 lazy initialization
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
let pool;
|
||||||
|
|
||||||
|
function getPool() {
|
||||||
|
if (!pool) {
|
||||||
|
pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || 'mariadb',
|
||||||
|
port: parseInt(process.env.DB_PORT) || 3306,
|
||||||
|
user: process.env.DB_USER || 'hyungi_user',
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME || 'hyungi',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: parseInt(process.env.DB_CONN_LIMIT) || 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getPool };
|
||||||
360
shared/middleware/auth.js
Normal file
360
shared/middleware/auth.js
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* 통합 인증/인가 미들웨어
|
||||||
|
*
|
||||||
|
* JWT 토큰 검증 및 권한 체크를 위한 미들웨어 모음
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { AuthenticationError, ForbiddenError } = require('../utils/errors');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// JWT_SECRET: system1/system2는 JWT_SECRET, 나머지는 SSO_JWT_SECRET 사용
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || process.env.SSO_JWT_SECRET;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한 레벨 계층 구조
|
||||||
|
* 숫자가 높을수록 상위 권한
|
||||||
|
*/
|
||||||
|
const ACCESS_LEVELS = {
|
||||||
|
worker: 1,
|
||||||
|
group_leader: 2,
|
||||||
|
support_team: 3,
|
||||||
|
admin: 4,
|
||||||
|
system: 5
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 토큰 검증 미들웨어
|
||||||
|
*
|
||||||
|
* Authorization 헤더에서 Bearer 토큰을 추출하고 검증합니다.
|
||||||
|
* 검증 성공 시 req.user에 디코딩된 사용자 정보를 저장합니다.
|
||||||
|
*
|
||||||
|
* @throws {AuthenticationError} 토큰이 없거나 유효하지 않을 때
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* router.get('/profile', requireAuth, getProfile);
|
||||||
|
*/
|
||||||
|
const requireAuth = (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
logger.warn('인증 실패: Authorization 헤더 없음', {
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
ip: req.ip
|
||||||
|
});
|
||||||
|
throw new AuthenticationError('Authorization 헤더가 필요합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
logger.warn('인증 실패: Bearer 토큰 누락', {
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
ip: req.ip
|
||||||
|
});
|
||||||
|
throw new AuthenticationError('Bearer 토큰이 필요합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT 검증 (SSO 공유 시크릿)
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
req.user = decoded;
|
||||||
|
|
||||||
|
logger.debug('인증 성공', {
|
||||||
|
user_id: decoded.user_id || decoded.id,
|
||||||
|
username: decoded.username,
|
||||||
|
role: decoded.role,
|
||||||
|
access_level: decoded.access_level
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'JsonWebTokenError') {
|
||||||
|
logger.warn('인증 실패: 유효하지 않은 토큰', {
|
||||||
|
error: err.message,
|
||||||
|
path: req.path,
|
||||||
|
ip: req.ip
|
||||||
|
});
|
||||||
|
throw new AuthenticationError('유효하지 않은 토큰입니다');
|
||||||
|
} else if (err.name === 'TokenExpiredError') {
|
||||||
|
logger.warn('인증 실패: 만료된 토큰', {
|
||||||
|
error: err.message,
|
||||||
|
path: req.path,
|
||||||
|
ip: req.ip
|
||||||
|
});
|
||||||
|
throw new AuthenticationError('토큰이 만료되었습니다');
|
||||||
|
} else if (err instanceof AuthenticationError) {
|
||||||
|
// 이미 AuthenticationError인 경우 그대로 throw
|
||||||
|
throw err;
|
||||||
|
} else {
|
||||||
|
logger.error('인증 처리 중 예상치 못한 오류', {
|
||||||
|
error: err.message,
|
||||||
|
stack: err.stack
|
||||||
|
});
|
||||||
|
throw new AuthenticationError('인증 처리 중 오류가 발생했습니다');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 역할(들) 권한 체크 미들웨어
|
||||||
|
*
|
||||||
|
* 사용자가 지정된 역할 중 하나를 가지고 있는지 확인합니다.
|
||||||
|
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||||
|
*
|
||||||
|
* @param {...string} roles - 허용할 역할 목록
|
||||||
|
* @returns {Function} Express 미들웨어 함수
|
||||||
|
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||||
|
* @throws {ForbiddenError} 권한이 없는 경우
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 단일 역할
|
||||||
|
* router.post('/admin/users', requireAuth, requireRole('admin'), createUser);
|
||||||
|
*
|
||||||
|
* // 여러 역할
|
||||||
|
* router.get('/reports', requireAuth, requireRole('admin', 'support_team'), getReports);
|
||||||
|
*/
|
||||||
|
const requireRole = (...roles) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
logger.warn('권한 체크 실패: 인증되지 않은 요청', {
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
ip: req.ip
|
||||||
|
});
|
||||||
|
throw new AuthenticationError('인증이 필요합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = req.user.role;
|
||||||
|
const userRoleLower = userRole ? userRole.toLowerCase() : '';
|
||||||
|
const rolesLower = roles.map(r => r.toLowerCase());
|
||||||
|
|
||||||
|
if (!rolesLower.includes(userRoleLower)) {
|
||||||
|
logger.warn('권한 체크 실패: 역할 불일치', {
|
||||||
|
user_id: req.user.user_id || req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
current_role: userRole,
|
||||||
|
required_roles: roles,
|
||||||
|
path: req.path
|
||||||
|
});
|
||||||
|
throw new ForbiddenError(
|
||||||
|
`이 기능을 사용하려면 ${roles.join(' 또는 ')} 권한이 필요합니다`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('역할 권한 확인 성공', {
|
||||||
|
user_id: req.user.user_id || req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
role: userRole,
|
||||||
|
required_roles: roles
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최소 권한 레벨 체크 미들웨어 (계층적)
|
||||||
|
*
|
||||||
|
* 사용자가 요구되는 최소 권한 레벨 이상인지 확인합니다.
|
||||||
|
* worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5)
|
||||||
|
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||||
|
*
|
||||||
|
* @param {string} minLevel - 최소 권한 레벨 (worker, group_leader, support_team, admin, system)
|
||||||
|
* @returns {Function} Express 미들웨어 함수
|
||||||
|
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||||
|
* @throws {ForbiddenError} 권한이 부족한 경우
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // admin 이상 필요 (admin, system만 허용)
|
||||||
|
* router.delete('/users/:id', requireAuth, requireMinLevel('admin'), deleteUser);
|
||||||
|
*
|
||||||
|
* // group_leader 이상 필요 (group_leader, support_team, admin, system 허용)
|
||||||
|
* router.get('/team-reports', requireAuth, requireMinLevel('group_leader'), getTeamReports);
|
||||||
|
*/
|
||||||
|
const requireMinLevel = (minLevel) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
logger.warn('권한 레벨 체크 실패: 인증되지 않은 요청', {
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
ip: req.ip
|
||||||
|
});
|
||||||
|
throw new AuthenticationError('인증이 필요합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLevel = ACCESS_LEVELS[req.user.access_level] || 0;
|
||||||
|
const requiredLevel = ACCESS_LEVELS[minLevel] || 999;
|
||||||
|
|
||||||
|
if (userLevel < requiredLevel) {
|
||||||
|
logger.warn('권한 레벨 체크 실패: 권한 부족', {
|
||||||
|
user_id: req.user.user_id || req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
current_level: req.user.access_level,
|
||||||
|
current_level_value: userLevel,
|
||||||
|
required_level: minLevel,
|
||||||
|
required_level_value: requiredLevel,
|
||||||
|
path: req.path
|
||||||
|
});
|
||||||
|
throw new ForbiddenError(
|
||||||
|
`이 기능을 사용하려면 ${minLevel} 이상의 권한이 필요합니다 (현재: ${req.user.access_level})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('권한 레벨 확인 성공', {
|
||||||
|
user_id: req.user.user_id || req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
access_level: req.user.access_level,
|
||||||
|
required_level: minLevel
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 소유자 또는 관리자 권한 체크 미들웨어
|
||||||
|
*
|
||||||
|
* 요청한 사용자가 리소스의 소유자이거나 관리자 권한이 있는지 확인합니다.
|
||||||
|
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
||||||
|
*
|
||||||
|
* @param {Object} options - 옵션 객체
|
||||||
|
* @param {string} options.resourceField - 리소스 ID를 가져올 req 필드 (예: 'params.user_id', 'body.user_id')
|
||||||
|
* @param {string} options.userField - 사용자 ID 필드명 (기본값: 'user_id', 'id'도 자동 시도)
|
||||||
|
* @param {string[]} options.adminRoles - 관리자로 인정할 역할들 (기본값: ['admin', 'system'])
|
||||||
|
* @returns {Function} Express 미들웨어 함수
|
||||||
|
* @throws {AuthenticationError} 인증되지 않은 경우
|
||||||
|
* @throws {ForbiddenError} 소유자도 아니고 관리자도 아닌 경우
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // URL 파라미터의 user_id로 체크
|
||||||
|
* router.put('/users/:user_id', requireAuth, requireOwnerOrAdmin({
|
||||||
|
* resourceField: 'params.user_id'
|
||||||
|
* }), updateUser);
|
||||||
|
*
|
||||||
|
* // 요청 body의 user_id로 체크, support_team도 관리자로 인정
|
||||||
|
* router.delete('/reports/:id', requireAuth, requireOwnerOrAdmin({
|
||||||
|
* resourceField: 'body.user_id',
|
||||||
|
* adminRoles: ['admin', 'system', 'support_team']
|
||||||
|
* }), deleteReport);
|
||||||
|
*/
|
||||||
|
const requireOwnerOrAdmin = (options = {}) => {
|
||||||
|
const {
|
||||||
|
resourceField = 'params.id',
|
||||||
|
userField = 'user_id',
|
||||||
|
adminRoles = ['admin', 'system']
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
logger.warn('소유자/관리자 체크 실패: 인증되지 않은 요청', {
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
ip: req.ip
|
||||||
|
});
|
||||||
|
throw new AuthenticationError('인증이 필요합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관리자 권한 체크
|
||||||
|
const userRole = req.user.role;
|
||||||
|
const isAdmin = adminRoles.includes(userRole);
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
logger.debug('관리자 권한으로 접근 허용', {
|
||||||
|
user_id: req.user.user_id || req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
role: userRole,
|
||||||
|
path: req.path
|
||||||
|
});
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리소스 ID 추출
|
||||||
|
const fieldParts = resourceField.split('.');
|
||||||
|
let resourceId = req;
|
||||||
|
for (const part of fieldParts) {
|
||||||
|
resourceId = resourceId[part];
|
||||||
|
if (resourceId === undefined) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 ID (user_id 또는 id)
|
||||||
|
const userId = req.user[userField] || req.user.id || req.user.user_id;
|
||||||
|
|
||||||
|
// 소유자 체크
|
||||||
|
const isOwner = resourceId && String(resourceId) === String(userId);
|
||||||
|
|
||||||
|
if (!isOwner) {
|
||||||
|
logger.warn('소유자/관리자 체크 실패: 권한 부족', {
|
||||||
|
user_id: userId,
|
||||||
|
username: req.user.username,
|
||||||
|
role: userRole,
|
||||||
|
resource_id: resourceId,
|
||||||
|
resource_field: resourceField,
|
||||||
|
is_admin: isAdmin,
|
||||||
|
is_owner: isOwner,
|
||||||
|
path: req.path
|
||||||
|
});
|
||||||
|
throw new ForbiddenError('본인의 리소스이거나 관리자 권한이 필요합니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('리소스 소유자로 접근 허용', {
|
||||||
|
user_id: userId,
|
||||||
|
username: req.user.username,
|
||||||
|
resource_id: resourceId,
|
||||||
|
path: req.path
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레거시 호환성을 위한 별칭
|
||||||
|
* @deprecated requireAuth를 사용하세요
|
||||||
|
*/
|
||||||
|
const verifyToken = requireAuth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레거시 호환성을 위한 별칭
|
||||||
|
* @deprecated requireRole('admin', 'system')을 사용하세요
|
||||||
|
*/
|
||||||
|
const requireAdmin = requireRole('admin', 'system');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레거시 호환성을 위한 별칭
|
||||||
|
* @deprecated requireRole('system')을 사용하세요
|
||||||
|
*/
|
||||||
|
const requireSystem = requireRole('system');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// 주요 미들웨어
|
||||||
|
requireAuth,
|
||||||
|
requireRole,
|
||||||
|
requireMinLevel,
|
||||||
|
requireOwnerOrAdmin,
|
||||||
|
|
||||||
|
// 레거시 호환성
|
||||||
|
verifyToken,
|
||||||
|
requireAdmin,
|
||||||
|
requireSystem,
|
||||||
|
|
||||||
|
// 상수
|
||||||
|
ACCESS_LEVELS
|
||||||
|
};
|
||||||
186
shared/utils/errors.js
Normal file
186
shared/utils/errors.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* 커스텀 에러 클래스
|
||||||
|
*
|
||||||
|
* 애플리케이션 전체에서 사용하는 표준화된 에러 클래스들
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 애플리케이션 에러 클래스
|
||||||
|
* 모든 커스텀 에러의 부모 클래스
|
||||||
|
*/
|
||||||
|
class AppError extends Error {
|
||||||
|
/**
|
||||||
|
* @param {string} message - 에러 메시지
|
||||||
|
* @param {number} statusCode - HTTP 상태 코드
|
||||||
|
* @param {string} code - 에러 코드 (예: 'VALIDATION_ERROR')
|
||||||
|
* @param {object} details - 추가 세부 정보
|
||||||
|
*/
|
||||||
|
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
this.isOperational = true; // 운영 에러 (예상된 에러)
|
||||||
|
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 형태로 에러 정보 반환
|
||||||
|
*/
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
message: this.message,
|
||||||
|
statusCode: this.statusCode,
|
||||||
|
code: this.code,
|
||||||
|
details: this.details
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증 에러 (400 Bad Request)
|
||||||
|
* 입력값 검증 실패 시 사용
|
||||||
|
*/
|
||||||
|
class ValidationError extends AppError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - 에러 메시지
|
||||||
|
* @param {object} details - 검증 실패 세부 정보
|
||||||
|
*/
|
||||||
|
constructor(message = '입력값이 올바르지 않습니다', details = null) {
|
||||||
|
super(message, 400, 'VALIDATION_ERROR', details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 에러 (401 Unauthorized)
|
||||||
|
* 인증이 필요하거나 인증 실패 시 사용
|
||||||
|
*/
|
||||||
|
class AuthenticationError extends AppError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - 에러 메시지
|
||||||
|
*/
|
||||||
|
constructor(message = '인증이 필요합니다') {
|
||||||
|
super(message, 401, 'AUTHENTICATION_ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한 에러 (403 Forbidden)
|
||||||
|
* 권한이 부족할 때 사용
|
||||||
|
*/
|
||||||
|
class ForbiddenError extends AppError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - 에러 메시지
|
||||||
|
*/
|
||||||
|
constructor(message = '권한이 없습니다') {
|
||||||
|
super(message, 403, 'FORBIDDEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 없음 에러 (404 Not Found)
|
||||||
|
* 요청한 리소스를 찾을 수 없을 때 사용
|
||||||
|
*/
|
||||||
|
class NotFoundError extends AppError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - 에러 메시지
|
||||||
|
* @param {string} resource - 찾을 수 없는 리소스명
|
||||||
|
*/
|
||||||
|
constructor(message = '리소스를 찾을 수 없습니다', resource = null) {
|
||||||
|
super(message, 404, 'NOT_FOUND', resource ? { resource } : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 충돌 에러 (409 Conflict)
|
||||||
|
* 중복된 리소스 등 충돌 발생 시 사용
|
||||||
|
*/
|
||||||
|
class ConflictError extends AppError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - 에러 메시지
|
||||||
|
* @param {object} details - 충돌 세부 정보
|
||||||
|
*/
|
||||||
|
constructor(message = '이미 존재하는 데이터입니다', details = null) {
|
||||||
|
super(message, 409, 'CONFLICT', details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 에러 (500 Internal Server Error)
|
||||||
|
* 예상하지 못한 서버 오류 시 사용
|
||||||
|
*/
|
||||||
|
class InternalServerError extends AppError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - 에러 메시지
|
||||||
|
*/
|
||||||
|
constructor(message = '서버 오류가 발생했습니다') {
|
||||||
|
super(message, 500, 'INTERNAL_SERVER_ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터베이스 에러 (500 Internal Server Error)
|
||||||
|
* DB 관련 오류 시 사용
|
||||||
|
*/
|
||||||
|
class DatabaseError extends AppError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - 에러 메시지
|
||||||
|
* @param {Error} originalError - 원본 DB 에러
|
||||||
|
*/
|
||||||
|
constructor(message = '데이터베이스 오류가 발생했습니다', originalError = null) {
|
||||||
|
super(
|
||||||
|
message,
|
||||||
|
500,
|
||||||
|
'DATABASE_ERROR',
|
||||||
|
originalError ? { originalMessage: originalError.message } : null
|
||||||
|
);
|
||||||
|
this.originalError = originalError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 API 에러 (502 Bad Gateway)
|
||||||
|
* 외부 서비스 호출 실패 시 사용
|
||||||
|
*/
|
||||||
|
class ExternalApiError extends AppError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - 에러 메시지
|
||||||
|
* @param {string} service - 외부 서비스명
|
||||||
|
*/
|
||||||
|
constructor(message = '외부 서비스 호출에 실패했습니다', service = null) {
|
||||||
|
super(message, 502, 'EXTERNAL_API_ERROR', service ? { service } : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임아웃 에러 (504 Gateway Timeout)
|
||||||
|
* 요청 처리 시간 초과 시 사용
|
||||||
|
*/
|
||||||
|
class TimeoutError extends AppError {
|
||||||
|
/**
|
||||||
|
* @param {string} message - 에러 메시지
|
||||||
|
* @param {number} timeout - 타임아웃 시간 (ms)
|
||||||
|
*/
|
||||||
|
constructor(message = '요청 처리 시간이 초과되었습니다', timeout = null) {
|
||||||
|
super(message, 504, 'TIMEOUT_ERROR', timeout ? { timeout } : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
AppError,
|
||||||
|
ValidationError,
|
||||||
|
AuthenticationError,
|
||||||
|
ForbiddenError,
|
||||||
|
NotFoundError,
|
||||||
|
ConflictError,
|
||||||
|
InternalServerError,
|
||||||
|
DatabaseError,
|
||||||
|
ExternalApiError,
|
||||||
|
TimeoutError
|
||||||
|
};
|
||||||
199
shared/utils/logger.js
Normal file
199
shared/utils/logger.js
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* 로깅 유틸리티
|
||||||
|
*
|
||||||
|
* 애플리케이션 전체에서 사용하는 통합 로거
|
||||||
|
*
|
||||||
|
* @author TK-FB-Project
|
||||||
|
* @since 2025-12-11
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 레벨 정의
|
||||||
|
*/
|
||||||
|
const LogLevel = {
|
||||||
|
ERROR: 'ERROR',
|
||||||
|
WARN: 'WARN',
|
||||||
|
INFO: 'INFO',
|
||||||
|
DEBUG: 'DEBUG'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 레벨별 이모지
|
||||||
|
*/
|
||||||
|
const LogEmoji = {
|
||||||
|
ERROR: '❌',
|
||||||
|
WARN: '⚠️',
|
||||||
|
INFO: 'ℹ️',
|
||||||
|
DEBUG: '🔍'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 레벨별 색상 (콘솔)
|
||||||
|
*/
|
||||||
|
const LogColor = {
|
||||||
|
ERROR: '\x1b[31m', // Red
|
||||||
|
WARN: '\x1b[33m', // Yellow
|
||||||
|
INFO: '\x1b[36m', // Cyan
|
||||||
|
DEBUG: '\x1b[90m', // Gray
|
||||||
|
RESET: '\x1b[0m'
|
||||||
|
};
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
constructor() {
|
||||||
|
// process.cwd() = /usr/src/app (컨테이너 WORKDIR)
|
||||||
|
// __dirname 대신 사용하여 shared/ 위치와 무관하게 서비스의 logs/ 디렉토리에 기록
|
||||||
|
this.logDir = path.join(process.cwd(), 'logs');
|
||||||
|
this.logFile = path.join(this.logDir, 'app.log');
|
||||||
|
this.errorFile = path.join(this.logDir, 'error.log');
|
||||||
|
this.ensureLogDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 디렉토리 생성
|
||||||
|
*/
|
||||||
|
ensureLogDirectory() {
|
||||||
|
if (!fs.existsSync(this.logDir)) {
|
||||||
|
fs.mkdirSync(this.logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임스탬프 생성
|
||||||
|
*/
|
||||||
|
getTimestamp() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 포맷팅
|
||||||
|
*/
|
||||||
|
formatLog(level, message, context = {}) {
|
||||||
|
const timestamp = this.getTimestamp();
|
||||||
|
const emoji = LogEmoji[level] || '';
|
||||||
|
const contextStr = Object.keys(context).length > 0
|
||||||
|
? `\n Context: ${JSON.stringify(context, null, 2)}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `[${timestamp}] [${level}] ${emoji} ${message}${contextStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘솔에 컬러 로그 출력
|
||||||
|
*/
|
||||||
|
logToConsole(level, message, context = {}) {
|
||||||
|
const color = LogColor[level] || LogColor.RESET;
|
||||||
|
const formattedLog = this.formatLog(level, message, context);
|
||||||
|
|
||||||
|
if (level === LogLevel.ERROR) {
|
||||||
|
console.error(`${color}${formattedLog}${LogColor.RESET}`);
|
||||||
|
} else if (level === LogLevel.WARN) {
|
||||||
|
console.warn(`${color}${formattedLog}${LogColor.RESET}`);
|
||||||
|
} else {
|
||||||
|
console.log(`${color}${formattedLog}${LogColor.RESET}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일에 로그 기록
|
||||||
|
*/
|
||||||
|
logToFile(level, message, context = {}) {
|
||||||
|
const formattedLog = this.formatLog(level, message, context);
|
||||||
|
const logEntry = `${formattedLog}\n`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 모든 로그를 app.log에 기록
|
||||||
|
fs.appendFileSync(this.logFile, logEntry, 'utf8');
|
||||||
|
|
||||||
|
// 에러는 error.log에도 기록
|
||||||
|
if (level === LogLevel.ERROR) {
|
||||||
|
fs.appendFileSync(this.errorFile, logEntry, 'utf8');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('로그 파일 기록 실패:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 기록 메인 함수
|
||||||
|
*/
|
||||||
|
log(level, message, context = {}) {
|
||||||
|
// 개발 환경에서는 콘솔에 출력
|
||||||
|
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV !== 'production') {
|
||||||
|
this.logToConsole(level, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로덕션에서는 파일에만 기록
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
this.logToFile(level, message, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 로그
|
||||||
|
*/
|
||||||
|
error(message, context = {}) {
|
||||||
|
this.log(LogLevel.ERROR, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 경고 로그
|
||||||
|
*/
|
||||||
|
warn(message, context = {}) {
|
||||||
|
this.log(LogLevel.WARN, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정보 로그
|
||||||
|
*/
|
||||||
|
info(message, context = {}) {
|
||||||
|
this.log(LogLevel.INFO, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디버그 로그
|
||||||
|
*/
|
||||||
|
debug(message, context = {}) {
|
||||||
|
// DEBUG 로그는 개발 환경에서만 출력
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
this.log(LogLevel.DEBUG, message, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 요청 로그
|
||||||
|
*/
|
||||||
|
http(method, url, statusCode, duration, user = 'anonymous') {
|
||||||
|
const level = statusCode >= 400 ? LogLevel.ERROR : LogLevel.INFO;
|
||||||
|
const message = `${method} ${url} - ${statusCode} (${duration}ms)`;
|
||||||
|
const context = {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
statusCode,
|
||||||
|
duration,
|
||||||
|
user
|
||||||
|
};
|
||||||
|
|
||||||
|
this.log(level, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터베이스 쿼리 로그
|
||||||
|
*/
|
||||||
|
query(sql, params = [], duration = 0) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
this.debug('DB Query', {
|
||||||
|
sql,
|
||||||
|
params,
|
||||||
|
duration: `${duration}ms`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 싱글톤 인스턴스 생성 및 내보내기
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// utils/notifyHelper.js — 공용 알림 헬퍼
|
// shared/utils/notifyHelper.js — 공용 알림 헬퍼
|
||||||
// tkuser-api의 내부 알림 API를 통해 DB 저장 + Push 전송
|
// tkuser-api의 내부 알림 API를 통해 DB 저장 + Push 전송
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
|
||||||
@@ -1,36 +1,27 @@
|
|||||||
# Node.js 공식 이미지 사용
|
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
# 작업 디렉토리 설정
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# 패키지 파일 복사 (캐싱 최적화)
|
# shared 모듈 복사
|
||||||
COPY package*.json ./
|
COPY shared/ ./shared/
|
||||||
|
|
||||||
# 프로덕션 의존성만 설치 (sharp용 빌드 도구 포함)
|
# 패키지 파일 복사 + 프로덕션 의존성 설치 (sharp용 빌드 도구 포함)
|
||||||
|
COPY system1-factory/api/package*.json ./
|
||||||
RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
|
RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
|
||||||
npm install --omit=dev && \
|
npm install --omit=dev && \
|
||||||
npm install sharp && \
|
npm install sharp && \
|
||||||
apk del .build-deps
|
apk del .build-deps
|
||||||
|
|
||||||
# 앱 소스 복사
|
# 앱 소스 복사
|
||||||
COPY . .
|
COPY system1-factory/api/ ./
|
||||||
|
|
||||||
# 로그 디렉토리 생성
|
# 로그/업로드 디렉토리 생성
|
||||||
RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests
|
RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests
|
||||||
|
|
||||||
# 실행 권한 설정
|
|
||||||
RUN chown -R node:node /usr/src/app
|
RUN chown -R node:node /usr/src/app
|
||||||
|
|
||||||
# 보안을 위해 non-root 사용자로 실행
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
# 포트 노출
|
|
||||||
EXPOSE 3005
|
EXPOSE 3005
|
||||||
|
|
||||||
# 헬스체크 추가
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||||
CMD node -e "require('http').get('http://localhost:3005/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
|
CMD node -e "require('http').get('http://localhost:3005/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
|
||||||
|
|
||||||
# 앱 시작
|
CMD ["node", "index.js"]
|
||||||
CMD ["node", "index.js"]
|
|
||||||
|
|||||||
@@ -1,357 +1 @@
|
|||||||
/**
|
module.exports = require('../shared/middleware/auth');
|
||||||
* 통합 인증/인가 미들웨어
|
|
||||||
*
|
|
||||||
* JWT 토큰 검증 및 권한 체크를 위한 미들웨어 모음
|
|
||||||
*
|
|
||||||
* @author TK-FB-Project
|
|
||||||
* @since 2025-12-11
|
|
||||||
*/
|
|
||||||
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const { AuthenticationError, ForbiddenError } = require('../utils/errors');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 레벨 계층 구조
|
|
||||||
* 숫자가 높을수록 상위 권한
|
|
||||||
*/
|
|
||||||
const ACCESS_LEVELS = {
|
|
||||||
worker: 1,
|
|
||||||
group_leader: 2,
|
|
||||||
support_team: 3,
|
|
||||||
admin: 4,
|
|
||||||
system: 5
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT 토큰 검증 미들웨어
|
|
||||||
*
|
|
||||||
* Authorization 헤더에서 Bearer 토큰을 추출하고 검증합니다.
|
|
||||||
* 검증 성공 시 req.user에 디코딩된 사용자 정보를 저장합니다.
|
|
||||||
*
|
|
||||||
* @throws {AuthenticationError} 토큰이 없거나 유효하지 않을 때
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* router.get('/profile', requireAuth, getProfile);
|
|
||||||
*/
|
|
||||||
const requireAuth = (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const authHeader = req.headers['authorization'];
|
|
||||||
|
|
||||||
if (!authHeader) {
|
|
||||||
logger.warn('인증 실패: Authorization 헤더 없음', {
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('Authorization 헤더가 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.split(' ')[1];
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
logger.warn('인증 실패: Bearer 토큰 누락', {
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('Bearer 토큰이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT 검증 (SSO 공유 시크릿 - docker-compose에서 JWT_SECRET=SSO_JWT_SECRET로 설정)
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
req.user = decoded;
|
|
||||||
|
|
||||||
logger.debug('인증 성공', {
|
|
||||||
user_id: decoded.user_id || decoded.id,
|
|
||||||
username: decoded.username,
|
|
||||||
role: decoded.role,
|
|
||||||
access_level: decoded.access_level
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
if (err.name === 'JsonWebTokenError') {
|
|
||||||
logger.warn('인증 실패: 유효하지 않은 토큰', {
|
|
||||||
error: err.message,
|
|
||||||
path: req.path,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('유효하지 않은 토큰입니다');
|
|
||||||
} else if (err.name === 'TokenExpiredError') {
|
|
||||||
logger.warn('인증 실패: 만료된 토큰', {
|
|
||||||
error: err.message,
|
|
||||||
path: req.path,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('토큰이 만료되었습니다');
|
|
||||||
} else if (err instanceof AuthenticationError) {
|
|
||||||
// 이미 AuthenticationError인 경우 그대로 throw
|
|
||||||
throw err;
|
|
||||||
} else {
|
|
||||||
logger.error('인증 처리 중 예상치 못한 오류', {
|
|
||||||
error: err.message,
|
|
||||||
stack: err.stack
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('인증 처리 중 오류가 발생했습니다');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 역할(들) 권한 체크 미들웨어
|
|
||||||
*
|
|
||||||
* 사용자가 지정된 역할 중 하나를 가지고 있는지 확인합니다.
|
|
||||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
|
||||||
*
|
|
||||||
* @param {...string} roles - 허용할 역할 목록
|
|
||||||
* @returns {Function} Express 미들웨어 함수
|
|
||||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
|
||||||
* @throws {ForbiddenError} 권한이 없는 경우
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // 단일 역할
|
|
||||||
* router.post('/admin/users', requireAuth, requireRole('admin'), createUser);
|
|
||||||
*
|
|
||||||
* // 여러 역할
|
|
||||||
* router.get('/reports', requireAuth, requireRole('admin', 'support_team'), getReports);
|
|
||||||
*/
|
|
||||||
const requireRole = (...roles) => {
|
|
||||||
return (req, res, next) => {
|
|
||||||
try {
|
|
||||||
if (!req.user) {
|
|
||||||
logger.warn('권한 체크 실패: 인증되지 않은 요청', {
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('인증이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRole = req.user.role;
|
|
||||||
const userRoleLower = userRole ? userRole.toLowerCase() : '';
|
|
||||||
const rolesLower = roles.map(r => r.toLowerCase());
|
|
||||||
|
|
||||||
if (!rolesLower.includes(userRoleLower)) {
|
|
||||||
logger.warn('권한 체크 실패: 역할 불일치', {
|
|
||||||
user_id: req.user.user_id || req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
current_role: userRole,
|
|
||||||
required_roles: roles,
|
|
||||||
path: req.path
|
|
||||||
});
|
|
||||||
throw new ForbiddenError(
|
|
||||||
`이 기능을 사용하려면 ${roles.join(' 또는 ')} 권한이 필요합니다`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('역할 권한 확인 성공', {
|
|
||||||
user_id: req.user.user_id || req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
role: userRole,
|
|
||||||
required_roles: roles
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최소 권한 레벨 체크 미들웨어 (계층적)
|
|
||||||
*
|
|
||||||
* 사용자가 요구되는 최소 권한 레벨 이상인지 확인합니다.
|
|
||||||
* worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5)
|
|
||||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
|
||||||
*
|
|
||||||
* @param {string} minLevel - 최소 권한 레벨 (worker, group_leader, support_team, admin, system)
|
|
||||||
* @returns {Function} Express 미들웨어 함수
|
|
||||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
|
||||||
* @throws {ForbiddenError} 권한이 부족한 경우
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // admin 이상 필요 (admin, system만 허용)
|
|
||||||
* router.delete('/users/:id', requireAuth, requireMinLevel('admin'), deleteUser);
|
|
||||||
*
|
|
||||||
* // group_leader 이상 필요 (group_leader, support_team, admin, system 허용)
|
|
||||||
* router.get('/team-reports', requireAuth, requireMinLevel('group_leader'), getTeamReports);
|
|
||||||
*/
|
|
||||||
const requireMinLevel = (minLevel) => {
|
|
||||||
return (req, res, next) => {
|
|
||||||
try {
|
|
||||||
if (!req.user) {
|
|
||||||
logger.warn('권한 레벨 체크 실패: 인증되지 않은 요청', {
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('인증이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userLevel = ACCESS_LEVELS[req.user.access_level] || 0;
|
|
||||||
const requiredLevel = ACCESS_LEVELS[minLevel] || 999;
|
|
||||||
|
|
||||||
if (userLevel < requiredLevel) {
|
|
||||||
logger.warn('권한 레벨 체크 실패: 권한 부족', {
|
|
||||||
user_id: req.user.user_id || req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
current_level: req.user.access_level,
|
|
||||||
current_level_value: userLevel,
|
|
||||||
required_level: minLevel,
|
|
||||||
required_level_value: requiredLevel,
|
|
||||||
path: req.path
|
|
||||||
});
|
|
||||||
throw new ForbiddenError(
|
|
||||||
`이 기능을 사용하려면 ${minLevel} 이상의 권한이 필요합니다 (현재: ${req.user.access_level})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('권한 레벨 확인 성공', {
|
|
||||||
user_id: req.user.user_id || req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
access_level: req.user.access_level,
|
|
||||||
required_level: minLevel
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 리소스 소유자 또는 관리자 권한 체크 미들웨어
|
|
||||||
*
|
|
||||||
* 요청한 사용자가 리소스의 소유자이거나 관리자 권한이 있는지 확인합니다.
|
|
||||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
|
||||||
*
|
|
||||||
* @param {Object} options - 옵션 객체
|
|
||||||
* @param {string} options.resourceField - 리소스 ID를 가져올 req 필드 (예: 'params.user_id', 'body.user_id')
|
|
||||||
* @param {string} options.userField - 사용자 ID 필드명 (기본값: 'user_id', 'id'도 자동 시도)
|
|
||||||
* @param {string[]} options.adminRoles - 관리자로 인정할 역할들 (기본값: ['admin', 'system'])
|
|
||||||
* @returns {Function} Express 미들웨어 함수
|
|
||||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
|
||||||
* @throws {ForbiddenError} 소유자도 아니고 관리자도 아닌 경우
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // URL 파라미터의 user_id로 체크
|
|
||||||
* router.put('/users/:user_id', requireAuth, requireOwnerOrAdmin({
|
|
||||||
* resourceField: 'params.user_id'
|
|
||||||
* }), updateUser);
|
|
||||||
*
|
|
||||||
* // 요청 body의 user_id로 체크, support_team도 관리자로 인정
|
|
||||||
* router.delete('/reports/:id', requireAuth, requireOwnerOrAdmin({
|
|
||||||
* resourceField: 'body.user_id',
|
|
||||||
* adminRoles: ['admin', 'system', 'support_team']
|
|
||||||
* }), deleteReport);
|
|
||||||
*/
|
|
||||||
const requireOwnerOrAdmin = (options = {}) => {
|
|
||||||
const {
|
|
||||||
resourceField = 'params.id',
|
|
||||||
userField = 'user_id',
|
|
||||||
adminRoles = ['admin', 'system']
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
return (req, res, next) => {
|
|
||||||
try {
|
|
||||||
if (!req.user) {
|
|
||||||
logger.warn('소유자/관리자 체크 실패: 인증되지 않은 요청', {
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('인증이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 관리자 권한 체크
|
|
||||||
const userRole = req.user.role;
|
|
||||||
const isAdmin = adminRoles.includes(userRole);
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
logger.debug('관리자 권한으로 접근 허용', {
|
|
||||||
user_id: req.user.user_id || req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
role: userRole,
|
|
||||||
path: req.path
|
|
||||||
});
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 리소스 ID 추출
|
|
||||||
const fieldParts = resourceField.split('.');
|
|
||||||
let resourceId = req;
|
|
||||||
for (const part of fieldParts) {
|
|
||||||
resourceId = resourceId[part];
|
|
||||||
if (resourceId === undefined) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 ID (user_id 또는 id)
|
|
||||||
const userId = req.user[userField] || req.user.id || req.user.user_id;
|
|
||||||
|
|
||||||
// 소유자 체크
|
|
||||||
const isOwner = resourceId && String(resourceId) === String(userId);
|
|
||||||
|
|
||||||
if (!isOwner) {
|
|
||||||
logger.warn('소유자/관리자 체크 실패: 권한 부족', {
|
|
||||||
user_id: userId,
|
|
||||||
username: req.user.username,
|
|
||||||
role: userRole,
|
|
||||||
resource_id: resourceId,
|
|
||||||
resource_field: resourceField,
|
|
||||||
is_admin: isAdmin,
|
|
||||||
is_owner: isOwner,
|
|
||||||
path: req.path
|
|
||||||
});
|
|
||||||
throw new ForbiddenError('본인의 리소스이거나 관리자 권한이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('리소스 소유자로 접근 허용', {
|
|
||||||
user_id: userId,
|
|
||||||
username: req.user.username,
|
|
||||||
resource_id: resourceId,
|
|
||||||
path: req.path
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레거시 호환성을 위한 별칭
|
|
||||||
* @deprecated requireAuth를 사용하세요
|
|
||||||
*/
|
|
||||||
const verifyToken = requireAuth;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레거시 호환성을 위한 별칭
|
|
||||||
* @deprecated requireRole('admin', 'system')을 사용하세요
|
|
||||||
*/
|
|
||||||
const requireAdmin = requireRole('admin', 'system');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레거시 호환성을 위한 별칭
|
|
||||||
* @deprecated requireRole('system')을 사용하세요
|
|
||||||
*/
|
|
||||||
const requireSystem = requireRole('system');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
// 주요 미들웨어
|
|
||||||
requireAuth,
|
|
||||||
requireRole,
|
|
||||||
requireMinLevel,
|
|
||||||
requireOwnerOrAdmin,
|
|
||||||
|
|
||||||
// 레거시 호환성
|
|
||||||
verifyToken,
|
|
||||||
requireAdmin,
|
|
||||||
requireSystem,
|
|
||||||
|
|
||||||
// 상수
|
|
||||||
ACCESS_LEVELS
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// models/equipmentModel.js
|
// models/equipmentModel.js
|
||||||
const { getDb } = require('../dbPool');
|
const { getDb } = require('../dbPool');
|
||||||
const notifyHelper = require('../utils/notifyHelper');
|
const notifyHelper = require('../shared/utils/notifyHelper');
|
||||||
|
|
||||||
const EquipmentModel = {
|
const EquipmentModel = {
|
||||||
// CREATE - 설비 생성
|
// CREATE - 설비 생성
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const PurchaseModel = {
|
|||||||
console.error('[purchase] 설비 자동 등록 실패:', err.message);
|
console.error('[purchase] 설비 자동 등록 실패:', err.message);
|
||||||
|
|
||||||
// fire-and-forget: admin 알림 전송
|
// fire-and-forget: admin 알림 전송
|
||||||
const notifyHelper = require('../utils/notifyHelper');
|
const notifyHelper = require('../shared/utils/notifyHelper');
|
||||||
notifyHelper.send({
|
notifyHelper.send({
|
||||||
type: 'equipment',
|
type: 'equipment',
|
||||||
title: `설비 자동 등록 실패: ${purchaseData.item_name}`,
|
title: `설비 자동 등록 실패: ${purchaseData.item_name}`,
|
||||||
|
|||||||
@@ -1,186 +1 @@
|
|||||||
/**
|
module.exports = require('../shared/utils/errors');
|
||||||
* 커스텀 에러 클래스
|
|
||||||
*
|
|
||||||
* 애플리케이션 전체에서 사용하는 표준화된 에러 클래스들
|
|
||||||
*
|
|
||||||
* @author TK-FB-Project
|
|
||||||
* @since 2025-12-11
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기본 애플리케이션 에러 클래스
|
|
||||||
* 모든 커스텀 에러의 부모 클래스
|
|
||||||
*/
|
|
||||||
class AppError extends Error {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {number} statusCode - HTTP 상태 코드
|
|
||||||
* @param {string} code - 에러 코드 (예: 'VALIDATION_ERROR')
|
|
||||||
* @param {object} details - 추가 세부 정보
|
|
||||||
*/
|
|
||||||
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
|
|
||||||
super(message);
|
|
||||||
this.name = this.constructor.name;
|
|
||||||
this.statusCode = statusCode;
|
|
||||||
this.code = code;
|
|
||||||
this.details = details;
|
|
||||||
this.isOperational = true; // 운영 에러 (예상된 에러)
|
|
||||||
|
|
||||||
Error.captureStackTrace(this, this.constructor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON 형태로 에러 정보 반환
|
|
||||||
*/
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
name: this.name,
|
|
||||||
message: this.message,
|
|
||||||
statusCode: this.statusCode,
|
|
||||||
code: this.code,
|
|
||||||
details: this.details
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 검증 에러 (400 Bad Request)
|
|
||||||
* 입력값 검증 실패 시 사용
|
|
||||||
*/
|
|
||||||
class ValidationError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {object} details - 검증 실패 세부 정보
|
|
||||||
*/
|
|
||||||
constructor(message = '입력값이 올바르지 않습니다', details = null) {
|
|
||||||
super(message, 400, 'VALIDATION_ERROR', details);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인증 에러 (401 Unauthorized)
|
|
||||||
* 인증이 필요하거나 인증 실패 시 사용
|
|
||||||
*/
|
|
||||||
class AuthenticationError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
*/
|
|
||||||
constructor(message = '인증이 필요합니다') {
|
|
||||||
super(message, 401, 'AUTHENTICATION_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 에러 (403 Forbidden)
|
|
||||||
* 권한이 부족할 때 사용
|
|
||||||
*/
|
|
||||||
class ForbiddenError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
*/
|
|
||||||
constructor(message = '권한이 없습니다') {
|
|
||||||
super(message, 403, 'FORBIDDEN');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 리소스 없음 에러 (404 Not Found)
|
|
||||||
* 요청한 리소스를 찾을 수 없을 때 사용
|
|
||||||
*/
|
|
||||||
class NotFoundError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {string} resource - 찾을 수 없는 리소스명
|
|
||||||
*/
|
|
||||||
constructor(message = '리소스를 찾을 수 없습니다', resource = null) {
|
|
||||||
super(message, 404, 'NOT_FOUND', resource ? { resource } : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 충돌 에러 (409 Conflict)
|
|
||||||
* 중복된 리소스 등 충돌 발생 시 사용
|
|
||||||
*/
|
|
||||||
class ConflictError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {object} details - 충돌 세부 정보
|
|
||||||
*/
|
|
||||||
constructor(message = '이미 존재하는 데이터입니다', details = null) {
|
|
||||||
super(message, 409, 'CONFLICT', details);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 서버 에러 (500 Internal Server Error)
|
|
||||||
* 예상하지 못한 서버 오류 시 사용
|
|
||||||
*/
|
|
||||||
class InternalServerError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
*/
|
|
||||||
constructor(message = '서버 오류가 발생했습니다') {
|
|
||||||
super(message, 500, 'INTERNAL_SERVER_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 데이터베이스 에러 (500 Internal Server Error)
|
|
||||||
* DB 관련 오류 시 사용
|
|
||||||
*/
|
|
||||||
class DatabaseError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {Error} originalError - 원본 DB 에러
|
|
||||||
*/
|
|
||||||
constructor(message = '데이터베이스 오류가 발생했습니다', originalError = null) {
|
|
||||||
super(
|
|
||||||
message,
|
|
||||||
500,
|
|
||||||
'DATABASE_ERROR',
|
|
||||||
originalError ? { originalMessage: originalError.message } : null
|
|
||||||
);
|
|
||||||
this.originalError = originalError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API 에러 (502 Bad Gateway)
|
|
||||||
* 외부 서비스 호출 실패 시 사용
|
|
||||||
*/
|
|
||||||
class ExternalApiError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {string} service - 외부 서비스명
|
|
||||||
*/
|
|
||||||
constructor(message = '외부 서비스 호출에 실패했습니다', service = null) {
|
|
||||||
super(message, 502, 'EXTERNAL_API_ERROR', service ? { service } : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 타임아웃 에러 (504 Gateway Timeout)
|
|
||||||
* 요청 처리 시간 초과 시 사용
|
|
||||||
*/
|
|
||||||
class TimeoutError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {number} timeout - 타임아웃 시간 (ms)
|
|
||||||
*/
|
|
||||||
constructor(message = '요청 처리 시간이 초과되었습니다', timeout = null) {
|
|
||||||
super(message, 504, 'TIMEOUT_ERROR', timeout ? { timeout } : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
AppError,
|
|
||||||
ValidationError,
|
|
||||||
AuthenticationError,
|
|
||||||
ForbiddenError,
|
|
||||||
NotFoundError,
|
|
||||||
ConflictError,
|
|
||||||
InternalServerError,
|
|
||||||
DatabaseError,
|
|
||||||
ExternalApiError,
|
|
||||||
TimeoutError
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,197 +1 @@
|
|||||||
/**
|
module.exports = require('../shared/utils/logger');
|
||||||
* 로깅 유틸리티
|
|
||||||
*
|
|
||||||
* 애플리케이션 전체에서 사용하는 통합 로거
|
|
||||||
*
|
|
||||||
* @author TK-FB-Project
|
|
||||||
* @since 2025-12-11
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 레벨 정의
|
|
||||||
*/
|
|
||||||
const LogLevel = {
|
|
||||||
ERROR: 'ERROR',
|
|
||||||
WARN: 'WARN',
|
|
||||||
INFO: 'INFO',
|
|
||||||
DEBUG: 'DEBUG'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 레벨별 이모지
|
|
||||||
*/
|
|
||||||
const LogEmoji = {
|
|
||||||
ERROR: '❌',
|
|
||||||
WARN: '⚠️',
|
|
||||||
INFO: 'ℹ️',
|
|
||||||
DEBUG: '🔍'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 레벨별 색상 (콘솔)
|
|
||||||
*/
|
|
||||||
const LogColor = {
|
|
||||||
ERROR: '\x1b[31m', // Red
|
|
||||||
WARN: '\x1b[33m', // Yellow
|
|
||||||
INFO: '\x1b[36m', // Cyan
|
|
||||||
DEBUG: '\x1b[90m', // Gray
|
|
||||||
RESET: '\x1b[0m'
|
|
||||||
};
|
|
||||||
|
|
||||||
class Logger {
|
|
||||||
constructor() {
|
|
||||||
this.logDir = path.join(__dirname, '../logs');
|
|
||||||
this.logFile = path.join(this.logDir, 'app.log');
|
|
||||||
this.errorFile = path.join(this.logDir, 'error.log');
|
|
||||||
this.ensureLogDirectory();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 디렉토리 생성
|
|
||||||
*/
|
|
||||||
ensureLogDirectory() {
|
|
||||||
if (!fs.existsSync(this.logDir)) {
|
|
||||||
fs.mkdirSync(this.logDir, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 타임스탬프 생성
|
|
||||||
*/
|
|
||||||
getTimestamp() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 포맷팅
|
|
||||||
*/
|
|
||||||
formatLog(level, message, context = {}) {
|
|
||||||
const timestamp = this.getTimestamp();
|
|
||||||
const emoji = LogEmoji[level] || '';
|
|
||||||
const contextStr = Object.keys(context).length > 0
|
|
||||||
? `\n Context: ${JSON.stringify(context, null, 2)}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `[${timestamp}] [${level}] ${emoji} ${message}${contextStr}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 콘솔에 컬러 로그 출력
|
|
||||||
*/
|
|
||||||
logToConsole(level, message, context = {}) {
|
|
||||||
const color = LogColor[level] || LogColor.RESET;
|
|
||||||
const formattedLog = this.formatLog(level, message, context);
|
|
||||||
|
|
||||||
if (level === LogLevel.ERROR) {
|
|
||||||
console.error(`${color}${formattedLog}${LogColor.RESET}`);
|
|
||||||
} else if (level === LogLevel.WARN) {
|
|
||||||
console.warn(`${color}${formattedLog}${LogColor.RESET}`);
|
|
||||||
} else {
|
|
||||||
console.log(`${color}${formattedLog}${LogColor.RESET}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 파일에 로그 기록
|
|
||||||
*/
|
|
||||||
logToFile(level, message, context = {}) {
|
|
||||||
const formattedLog = this.formatLog(level, message, context);
|
|
||||||
const logEntry = `${formattedLog}\n`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 모든 로그를 app.log에 기록
|
|
||||||
fs.appendFileSync(this.logFile, logEntry, 'utf8');
|
|
||||||
|
|
||||||
// 에러는 error.log에도 기록
|
|
||||||
if (level === LogLevel.ERROR) {
|
|
||||||
fs.appendFileSync(this.errorFile, logEntry, 'utf8');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('로그 파일 기록 실패:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 기록 메인 함수
|
|
||||||
*/
|
|
||||||
log(level, message, context = {}) {
|
|
||||||
// 개발 환경에서는 콘솔에 출력
|
|
||||||
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV !== 'production') {
|
|
||||||
this.logToConsole(level, message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프로덕션에서는 파일에만 기록
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
this.logToFile(level, message, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 에러 로그
|
|
||||||
*/
|
|
||||||
error(message, context = {}) {
|
|
||||||
this.log(LogLevel.ERROR, message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 경고 로그
|
|
||||||
*/
|
|
||||||
warn(message, context = {}) {
|
|
||||||
this.log(LogLevel.WARN, message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 정보 로그
|
|
||||||
*/
|
|
||||||
info(message, context = {}) {
|
|
||||||
this.log(LogLevel.INFO, message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 디버그 로그
|
|
||||||
*/
|
|
||||||
debug(message, context = {}) {
|
|
||||||
// DEBUG 로그는 개발 환경에서만 출력
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
this.log(LogLevel.DEBUG, message, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP 요청 로그
|
|
||||||
*/
|
|
||||||
http(method, url, statusCode, duration, user = 'anonymous') {
|
|
||||||
const level = statusCode >= 400 ? LogLevel.ERROR : LogLevel.INFO;
|
|
||||||
const message = `${method} ${url} - ${statusCode} (${duration}ms)`;
|
|
||||||
const context = {
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
statusCode,
|
|
||||||
duration,
|
|
||||||
user
|
|
||||||
};
|
|
||||||
|
|
||||||
this.log(level, message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 데이터베이스 쿼리 로그
|
|
||||||
*/
|
|
||||||
query(sql, params = [], duration = 0) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
this.debug('DB Query', {
|
|
||||||
sql,
|
|
||||||
params,
|
|
||||||
duration: `${duration}ms`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 싱글톤 인스턴스 생성 및 내보내기
|
|
||||||
const logger = new Logger();
|
|
||||||
|
|
||||||
module.exports = logger;
|
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ RUN apk add --no-cache imagemagick libheif imagemagick-heic imagemagick-jpeg
|
|||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package*.json ./
|
# shared 모듈 복사
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
|
||||||
|
COPY system2-report/api/package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
COPY . .
|
COPY system2-report/api/ ./
|
||||||
|
|
||||||
RUN mkdir -p logs uploads
|
RUN mkdir -p logs uploads
|
||||||
RUN chown -R node:node /usr/src/app
|
RUN chown -R node:node /usr/src/app
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
const workIssueModel = require('../models/workIssueModel');
|
const workIssueModel = require('../models/workIssueModel');
|
||||||
const imageUploadService = require('../services/imageUploadService');
|
const imageUploadService = require('../services/imageUploadService');
|
||||||
const mProjectService = require('../services/mProjectService');
|
const mProjectService = require('../services/mProjectService');
|
||||||
const notify = require('../utils/notifyHelper');
|
const notify = require('../shared/utils/notifyHelper');
|
||||||
|
|
||||||
// ==================== 신고 카테고리 관리 ====================
|
// ==================== 신고 카테고리 관리 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -1,357 +1 @@
|
|||||||
/**
|
module.exports = require('../shared/middleware/auth');
|
||||||
* 통합 인증/인가 미들웨어
|
|
||||||
*
|
|
||||||
* JWT 토큰 검증 및 권한 체크를 위한 미들웨어 모음
|
|
||||||
*
|
|
||||||
* @author TK-FB-Project
|
|
||||||
* @since 2025-12-11
|
|
||||||
*/
|
|
||||||
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const { AuthenticationError, ForbiddenError } = require('../utils/errors');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 레벨 계층 구조
|
|
||||||
* 숫자가 높을수록 상위 권한
|
|
||||||
*/
|
|
||||||
const ACCESS_LEVELS = {
|
|
||||||
worker: 1,
|
|
||||||
group_leader: 2,
|
|
||||||
support_team: 3,
|
|
||||||
admin: 4,
|
|
||||||
system: 5
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT 토큰 검증 미들웨어
|
|
||||||
*
|
|
||||||
* Authorization 헤더에서 Bearer 토큰을 추출하고 검증합니다.
|
|
||||||
* 검증 성공 시 req.user에 디코딩된 사용자 정보를 저장합니다.
|
|
||||||
*
|
|
||||||
* @throws {AuthenticationError} 토큰이 없거나 유효하지 않을 때
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* router.get('/profile', requireAuth, getProfile);
|
|
||||||
*/
|
|
||||||
const requireAuth = (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const authHeader = req.headers['authorization'];
|
|
||||||
|
|
||||||
if (!authHeader) {
|
|
||||||
logger.warn('인증 실패: Authorization 헤더 없음', {
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('Authorization 헤더가 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.split(' ')[1];
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
logger.warn('인증 실패: Bearer 토큰 누락', {
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('Bearer 토큰이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT 검증
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
req.user = decoded;
|
|
||||||
|
|
||||||
logger.debug('인증 성공', {
|
|
||||||
user_id: decoded.user_id || decoded.id,
|
|
||||||
username: decoded.username,
|
|
||||||
role: decoded.role,
|
|
||||||
access_level: decoded.access_level
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
if (err.name === 'JsonWebTokenError') {
|
|
||||||
logger.warn('인증 실패: 유효하지 않은 토큰', {
|
|
||||||
error: err.message,
|
|
||||||
path: req.path,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('유효하지 않은 토큰입니다');
|
|
||||||
} else if (err.name === 'TokenExpiredError') {
|
|
||||||
logger.warn('인증 실패: 만료된 토큰', {
|
|
||||||
error: err.message,
|
|
||||||
path: req.path,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('토큰이 만료되었습니다');
|
|
||||||
} else if (err instanceof AuthenticationError) {
|
|
||||||
// 이미 AuthenticationError인 경우 그대로 throw
|
|
||||||
throw err;
|
|
||||||
} else {
|
|
||||||
logger.error('인증 처리 중 예상치 못한 오류', {
|
|
||||||
error: err.message,
|
|
||||||
stack: err.stack
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('인증 처리 중 오류가 발생했습니다');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 역할(들) 권한 체크 미들웨어
|
|
||||||
*
|
|
||||||
* 사용자가 지정된 역할 중 하나를 가지고 있는지 확인합니다.
|
|
||||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
|
||||||
*
|
|
||||||
* @param {...string} roles - 허용할 역할 목록
|
|
||||||
* @returns {Function} Express 미들웨어 함수
|
|
||||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
|
||||||
* @throws {ForbiddenError} 권한이 없는 경우
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // 단일 역할
|
|
||||||
* router.post('/admin/users', requireAuth, requireRole('admin'), createUser);
|
|
||||||
*
|
|
||||||
* // 여러 역할
|
|
||||||
* router.get('/reports', requireAuth, requireRole('admin', 'support_team'), getReports);
|
|
||||||
*/
|
|
||||||
const requireRole = (...roles) => {
|
|
||||||
return (req, res, next) => {
|
|
||||||
try {
|
|
||||||
if (!req.user) {
|
|
||||||
logger.warn('권한 체크 실패: 인증되지 않은 요청', {
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('인증이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRole = req.user.role;
|
|
||||||
const userRoleLower = userRole ? userRole.toLowerCase() : '';
|
|
||||||
const rolesLower = roles.map(r => r.toLowerCase());
|
|
||||||
|
|
||||||
if (!rolesLower.includes(userRoleLower)) {
|
|
||||||
logger.warn('권한 체크 실패: 역할 불일치', {
|
|
||||||
user_id: req.user.user_id || req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
current_role: userRole,
|
|
||||||
required_roles: roles,
|
|
||||||
path: req.path
|
|
||||||
});
|
|
||||||
throw new ForbiddenError(
|
|
||||||
`이 기능을 사용하려면 ${roles.join(' 또는 ')} 권한이 필요합니다`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('역할 권한 확인 성공', {
|
|
||||||
user_id: req.user.user_id || req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
role: userRole,
|
|
||||||
required_roles: roles
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최소 권한 레벨 체크 미들웨어 (계층적)
|
|
||||||
*
|
|
||||||
* 사용자가 요구되는 최소 권한 레벨 이상인지 확인합니다.
|
|
||||||
* worker(1) < group_leader(2) < support_team(3) < admin(4) < system(5)
|
|
||||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
|
||||||
*
|
|
||||||
* @param {string} minLevel - 최소 권한 레벨 (worker, group_leader, support_team, admin, system)
|
|
||||||
* @returns {Function} Express 미들웨어 함수
|
|
||||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
|
||||||
* @throws {ForbiddenError} 권한이 부족한 경우
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // admin 이상 필요 (admin, system만 허용)
|
|
||||||
* router.delete('/users/:id', requireAuth, requireMinLevel('admin'), deleteUser);
|
|
||||||
*
|
|
||||||
* // group_leader 이상 필요 (group_leader, support_team, admin, system 허용)
|
|
||||||
* router.get('/team-reports', requireAuth, requireMinLevel('group_leader'), getTeamReports);
|
|
||||||
*/
|
|
||||||
const requireMinLevel = (minLevel) => {
|
|
||||||
return (req, res, next) => {
|
|
||||||
try {
|
|
||||||
if (!req.user) {
|
|
||||||
logger.warn('권한 레벨 체크 실패: 인증되지 않은 요청', {
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('인증이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userLevel = ACCESS_LEVELS[req.user.access_level] || 0;
|
|
||||||
const requiredLevel = ACCESS_LEVELS[minLevel] || 999;
|
|
||||||
|
|
||||||
if (userLevel < requiredLevel) {
|
|
||||||
logger.warn('권한 레벨 체크 실패: 권한 부족', {
|
|
||||||
user_id: req.user.user_id || req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
current_level: req.user.access_level,
|
|
||||||
current_level_value: userLevel,
|
|
||||||
required_level: minLevel,
|
|
||||||
required_level_value: requiredLevel,
|
|
||||||
path: req.path
|
|
||||||
});
|
|
||||||
throw new ForbiddenError(
|
|
||||||
`이 기능을 사용하려면 ${minLevel} 이상의 권한이 필요합니다 (현재: ${req.user.access_level})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('권한 레벨 확인 성공', {
|
|
||||||
user_id: req.user.user_id || req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
access_level: req.user.access_level,
|
|
||||||
required_level: minLevel
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 리소스 소유자 또는 관리자 권한 체크 미들웨어
|
|
||||||
*
|
|
||||||
* 요청한 사용자가 리소스의 소유자이거나 관리자 권한이 있는지 확인합니다.
|
|
||||||
* requireAuth 미들웨어가 먼저 실행되어야 합니다.
|
|
||||||
*
|
|
||||||
* @param {Object} options - 옵션 객체
|
|
||||||
* @param {string} options.resourceField - 리소스 ID를 가져올 req 필드 (예: 'params.user_id', 'body.worker_id')
|
|
||||||
* @param {string} options.userField - 사용자 ID 필드명 (기본값: 'user_id', 'id'도 자동 시도)
|
|
||||||
* @param {string[]} options.adminRoles - 관리자로 인정할 역할들 (기본값: ['admin', 'system'])
|
|
||||||
* @returns {Function} Express 미들웨어 함수
|
|
||||||
* @throws {AuthenticationError} 인증되지 않은 경우
|
|
||||||
* @throws {ForbiddenError} 소유자도 아니고 관리자도 아닌 경우
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // URL 파라미터의 user_id로 체크
|
|
||||||
* router.put('/users/:user_id', requireAuth, requireOwnerOrAdmin({
|
|
||||||
* resourceField: 'params.user_id'
|
|
||||||
* }), updateUser);
|
|
||||||
*
|
|
||||||
* // 요청 body의 worker_id로 체크, support_team도 관리자로 인정
|
|
||||||
* router.delete('/reports/:id', requireAuth, requireOwnerOrAdmin({
|
|
||||||
* resourceField: 'body.worker_id',
|
|
||||||
* adminRoles: ['admin', 'system', 'support_team']
|
|
||||||
* }), deleteReport);
|
|
||||||
*/
|
|
||||||
const requireOwnerOrAdmin = (options = {}) => {
|
|
||||||
const {
|
|
||||||
resourceField = 'params.id',
|
|
||||||
userField = 'user_id',
|
|
||||||
adminRoles = ['admin', 'system']
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
return (req, res, next) => {
|
|
||||||
try {
|
|
||||||
if (!req.user) {
|
|
||||||
logger.warn('소유자/관리자 체크 실패: 인증되지 않은 요청', {
|
|
||||||
path: req.path,
|
|
||||||
method: req.method,
|
|
||||||
ip: req.ip
|
|
||||||
});
|
|
||||||
throw new AuthenticationError('인증이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 관리자 권한 체크
|
|
||||||
const userRole = req.user.role;
|
|
||||||
const isAdmin = adminRoles.includes(userRole);
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
logger.debug('관리자 권한으로 접근 허용', {
|
|
||||||
user_id: req.user.user_id || req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
role: userRole,
|
|
||||||
path: req.path
|
|
||||||
});
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 리소스 ID 추출
|
|
||||||
const fieldParts = resourceField.split('.');
|
|
||||||
let resourceId = req;
|
|
||||||
for (const part of fieldParts) {
|
|
||||||
resourceId = resourceId[part];
|
|
||||||
if (resourceId === undefined) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 ID (user_id 또는 id)
|
|
||||||
const userId = req.user[userField] || req.user.id || req.user.user_id;
|
|
||||||
|
|
||||||
// 소유자 체크
|
|
||||||
const isOwner = resourceId && String(resourceId) === String(userId);
|
|
||||||
|
|
||||||
if (!isOwner) {
|
|
||||||
logger.warn('소유자/관리자 체크 실패: 권한 부족', {
|
|
||||||
user_id: userId,
|
|
||||||
username: req.user.username,
|
|
||||||
role: userRole,
|
|
||||||
resource_id: resourceId,
|
|
||||||
resource_field: resourceField,
|
|
||||||
is_admin: isAdmin,
|
|
||||||
is_owner: isOwner,
|
|
||||||
path: req.path
|
|
||||||
});
|
|
||||||
throw new ForbiddenError('본인의 리소스이거나 관리자 권한이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('리소스 소유자로 접근 허용', {
|
|
||||||
user_id: userId,
|
|
||||||
username: req.user.username,
|
|
||||||
resource_id: resourceId,
|
|
||||||
path: req.path
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레거시 호환성을 위한 별칭
|
|
||||||
* @deprecated requireAuth를 사용하세요
|
|
||||||
*/
|
|
||||||
const verifyToken = requireAuth;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레거시 호환성을 위한 별칭
|
|
||||||
* @deprecated requireRole('admin', 'system')을 사용하세요
|
|
||||||
*/
|
|
||||||
const requireAdmin = requireRole('admin', 'system');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레거시 호환성을 위한 별칭
|
|
||||||
* @deprecated requireRole('system')을 사용하세요
|
|
||||||
*/
|
|
||||||
const requireSystem = requireRole('system');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
// 주요 미들웨어
|
|
||||||
requireAuth,
|
|
||||||
requireRole,
|
|
||||||
requireMinLevel,
|
|
||||||
requireOwnerOrAdmin,
|
|
||||||
|
|
||||||
// 레거시 호환성
|
|
||||||
verifyToken,
|
|
||||||
requireAdmin,
|
|
||||||
requireSystem,
|
|
||||||
|
|
||||||
// 상수
|
|
||||||
ACCESS_LEVELS
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,186 +1 @@
|
|||||||
/**
|
module.exports = require('../shared/utils/errors');
|
||||||
* 커스텀 에러 클래스
|
|
||||||
*
|
|
||||||
* 애플리케이션 전체에서 사용하는 표준화된 에러 클래스들
|
|
||||||
*
|
|
||||||
* @author TK-FB-Project
|
|
||||||
* @since 2025-12-11
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기본 애플리케이션 에러 클래스
|
|
||||||
* 모든 커스텀 에러의 부모 클래스
|
|
||||||
*/
|
|
||||||
class AppError extends Error {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {number} statusCode - HTTP 상태 코드
|
|
||||||
* @param {string} code - 에러 코드 (예: 'VALIDATION_ERROR')
|
|
||||||
* @param {object} details - 추가 세부 정보
|
|
||||||
*/
|
|
||||||
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
|
|
||||||
super(message);
|
|
||||||
this.name = this.constructor.name;
|
|
||||||
this.statusCode = statusCode;
|
|
||||||
this.code = code;
|
|
||||||
this.details = details;
|
|
||||||
this.isOperational = true; // 운영 에러 (예상된 에러)
|
|
||||||
|
|
||||||
Error.captureStackTrace(this, this.constructor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON 형태로 에러 정보 반환
|
|
||||||
*/
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
name: this.name,
|
|
||||||
message: this.message,
|
|
||||||
statusCode: this.statusCode,
|
|
||||||
code: this.code,
|
|
||||||
details: this.details
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 검증 에러 (400 Bad Request)
|
|
||||||
* 입력값 검증 실패 시 사용
|
|
||||||
*/
|
|
||||||
class ValidationError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {object} details - 검증 실패 세부 정보
|
|
||||||
*/
|
|
||||||
constructor(message = '입력값이 올바르지 않습니다', details = null) {
|
|
||||||
super(message, 400, 'VALIDATION_ERROR', details);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인증 에러 (401 Unauthorized)
|
|
||||||
* 인증이 필요하거나 인증 실패 시 사용
|
|
||||||
*/
|
|
||||||
class AuthenticationError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
*/
|
|
||||||
constructor(message = '인증이 필요합니다') {
|
|
||||||
super(message, 401, 'AUTHENTICATION_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 에러 (403 Forbidden)
|
|
||||||
* 권한이 부족할 때 사용
|
|
||||||
*/
|
|
||||||
class ForbiddenError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
*/
|
|
||||||
constructor(message = '권한이 없습니다') {
|
|
||||||
super(message, 403, 'FORBIDDEN');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 리소스 없음 에러 (404 Not Found)
|
|
||||||
* 요청한 리소스를 찾을 수 없을 때 사용
|
|
||||||
*/
|
|
||||||
class NotFoundError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {string} resource - 찾을 수 없는 리소스명
|
|
||||||
*/
|
|
||||||
constructor(message = '리소스를 찾을 수 없습니다', resource = null) {
|
|
||||||
super(message, 404, 'NOT_FOUND', resource ? { resource } : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 충돌 에러 (409 Conflict)
|
|
||||||
* 중복된 리소스 등 충돌 발생 시 사용
|
|
||||||
*/
|
|
||||||
class ConflictError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {object} details - 충돌 세부 정보
|
|
||||||
*/
|
|
||||||
constructor(message = '이미 존재하는 데이터입니다', details = null) {
|
|
||||||
super(message, 409, 'CONFLICT', details);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 서버 에러 (500 Internal Server Error)
|
|
||||||
* 예상하지 못한 서버 오류 시 사용
|
|
||||||
*/
|
|
||||||
class InternalServerError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
*/
|
|
||||||
constructor(message = '서버 오류가 발생했습니다') {
|
|
||||||
super(message, 500, 'INTERNAL_SERVER_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 데이터베이스 에러 (500 Internal Server Error)
|
|
||||||
* DB 관련 오류 시 사용
|
|
||||||
*/
|
|
||||||
class DatabaseError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {Error} originalError - 원본 DB 에러
|
|
||||||
*/
|
|
||||||
constructor(message = '데이터베이스 오류가 발생했습니다', originalError = null) {
|
|
||||||
super(
|
|
||||||
message,
|
|
||||||
500,
|
|
||||||
'DATABASE_ERROR',
|
|
||||||
originalError ? { originalMessage: originalError.message } : null
|
|
||||||
);
|
|
||||||
this.originalError = originalError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API 에러 (502 Bad Gateway)
|
|
||||||
* 외부 서비스 호출 실패 시 사용
|
|
||||||
*/
|
|
||||||
class ExternalApiError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {string} service - 외부 서비스명
|
|
||||||
*/
|
|
||||||
constructor(message = '외부 서비스 호출에 실패했습니다', service = null) {
|
|
||||||
super(message, 502, 'EXTERNAL_API_ERROR', service ? { service } : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 타임아웃 에러 (504 Gateway Timeout)
|
|
||||||
* 요청 처리 시간 초과 시 사용
|
|
||||||
*/
|
|
||||||
class TimeoutError extends AppError {
|
|
||||||
/**
|
|
||||||
* @param {string} message - 에러 메시지
|
|
||||||
* @param {number} timeout - 타임아웃 시간 (ms)
|
|
||||||
*/
|
|
||||||
constructor(message = '요청 처리 시간이 초과되었습니다', timeout = null) {
|
|
||||||
super(message, 504, 'TIMEOUT_ERROR', timeout ? { timeout } : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
AppError,
|
|
||||||
ValidationError,
|
|
||||||
AuthenticationError,
|
|
||||||
ForbiddenError,
|
|
||||||
NotFoundError,
|
|
||||||
ConflictError,
|
|
||||||
InternalServerError,
|
|
||||||
DatabaseError,
|
|
||||||
ExternalApiError,
|
|
||||||
TimeoutError
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,197 +1 @@
|
|||||||
/**
|
module.exports = require('../shared/utils/logger');
|
||||||
* 로깅 유틸리티
|
|
||||||
*
|
|
||||||
* 애플리케이션 전체에서 사용하는 통합 로거
|
|
||||||
*
|
|
||||||
* @author TK-FB-Project
|
|
||||||
* @since 2025-12-11
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 레벨 정의
|
|
||||||
*/
|
|
||||||
const LogLevel = {
|
|
||||||
ERROR: 'ERROR',
|
|
||||||
WARN: 'WARN',
|
|
||||||
INFO: 'INFO',
|
|
||||||
DEBUG: 'DEBUG'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 레벨별 이모지
|
|
||||||
*/
|
|
||||||
const LogEmoji = {
|
|
||||||
ERROR: '❌',
|
|
||||||
WARN: '⚠️',
|
|
||||||
INFO: 'ℹ️',
|
|
||||||
DEBUG: '🔍'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 레벨별 색상 (콘솔)
|
|
||||||
*/
|
|
||||||
const LogColor = {
|
|
||||||
ERROR: '\x1b[31m', // Red
|
|
||||||
WARN: '\x1b[33m', // Yellow
|
|
||||||
INFO: '\x1b[36m', // Cyan
|
|
||||||
DEBUG: '\x1b[90m', // Gray
|
|
||||||
RESET: '\x1b[0m'
|
|
||||||
};
|
|
||||||
|
|
||||||
class Logger {
|
|
||||||
constructor() {
|
|
||||||
this.logDir = path.join(__dirname, '../logs');
|
|
||||||
this.logFile = path.join(this.logDir, 'app.log');
|
|
||||||
this.errorFile = path.join(this.logDir, 'error.log');
|
|
||||||
this.ensureLogDirectory();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 디렉토리 생성
|
|
||||||
*/
|
|
||||||
ensureLogDirectory() {
|
|
||||||
if (!fs.existsSync(this.logDir)) {
|
|
||||||
fs.mkdirSync(this.logDir, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 타임스탬프 생성
|
|
||||||
*/
|
|
||||||
getTimestamp() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 포맷팅
|
|
||||||
*/
|
|
||||||
formatLog(level, message, context = {}) {
|
|
||||||
const timestamp = this.getTimestamp();
|
|
||||||
const emoji = LogEmoji[level] || '';
|
|
||||||
const contextStr = Object.keys(context).length > 0
|
|
||||||
? `\n Context: ${JSON.stringify(context, null, 2)}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `[${timestamp}] [${level}] ${emoji} ${message}${contextStr}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 콘솔에 컬러 로그 출력
|
|
||||||
*/
|
|
||||||
logToConsole(level, message, context = {}) {
|
|
||||||
const color = LogColor[level] || LogColor.RESET;
|
|
||||||
const formattedLog = this.formatLog(level, message, context);
|
|
||||||
|
|
||||||
if (level === LogLevel.ERROR) {
|
|
||||||
console.error(`${color}${formattedLog}${LogColor.RESET}`);
|
|
||||||
} else if (level === LogLevel.WARN) {
|
|
||||||
console.warn(`${color}${formattedLog}${LogColor.RESET}`);
|
|
||||||
} else {
|
|
||||||
console.log(`${color}${formattedLog}${LogColor.RESET}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 파일에 로그 기록
|
|
||||||
*/
|
|
||||||
logToFile(level, message, context = {}) {
|
|
||||||
const formattedLog = this.formatLog(level, message, context);
|
|
||||||
const logEntry = `${formattedLog}\n`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 모든 로그를 app.log에 기록
|
|
||||||
fs.appendFileSync(this.logFile, logEntry, 'utf8');
|
|
||||||
|
|
||||||
// 에러는 error.log에도 기록
|
|
||||||
if (level === LogLevel.ERROR) {
|
|
||||||
fs.appendFileSync(this.errorFile, logEntry, 'utf8');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('로그 파일 기록 실패:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 기록 메인 함수
|
|
||||||
*/
|
|
||||||
log(level, message, context = {}) {
|
|
||||||
// 개발 환경에서는 콘솔에 출력
|
|
||||||
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV !== 'production') {
|
|
||||||
this.logToConsole(level, message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프로덕션에서는 파일에만 기록
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
this.logToFile(level, message, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 에러 로그
|
|
||||||
*/
|
|
||||||
error(message, context = {}) {
|
|
||||||
this.log(LogLevel.ERROR, message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 경고 로그
|
|
||||||
*/
|
|
||||||
warn(message, context = {}) {
|
|
||||||
this.log(LogLevel.WARN, message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 정보 로그
|
|
||||||
*/
|
|
||||||
info(message, context = {}) {
|
|
||||||
this.log(LogLevel.INFO, message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 디버그 로그
|
|
||||||
*/
|
|
||||||
debug(message, context = {}) {
|
|
||||||
// DEBUG 로그는 개발 환경에서만 출력
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
this.log(LogLevel.DEBUG, message, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP 요청 로그
|
|
||||||
*/
|
|
||||||
http(method, url, statusCode, duration, user = 'anonymous') {
|
|
||||||
const level = statusCode >= 400 ? LogLevel.ERROR : LogLevel.INFO;
|
|
||||||
const message = `${method} ${url} - ${statusCode} (${duration}ms)`;
|
|
||||||
const context = {
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
statusCode,
|
|
||||||
duration,
|
|
||||||
user
|
|
||||||
};
|
|
||||||
|
|
||||||
this.log(level, message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 데이터베이스 쿼리 로그
|
|
||||||
*/
|
|
||||||
query(sql, params = [], duration = 0) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
this.debug('DB Query', {
|
|
||||||
sql,
|
|
||||||
params,
|
|
||||||
duration: `${duration}ms`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 싱글톤 인스턴스 생성 및 내보내기
|
|
||||||
const logger = new Logger();
|
|
||||||
|
|
||||||
module.exports = logger;
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
// utils/notifyHelper.js — 공용 알림 헬퍼
|
|
||||||
// system1-factory의 내부 알림 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,11 +1,13 @@
|
|||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package*.json ./
|
# shared 모듈 복사
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
|
||||||
|
COPY tkpurchase/api/package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
COPY . .
|
COPY tkpurchase/api/ ./
|
||||||
|
|
||||||
RUN chown -R node:node /usr/src/app
|
RUN chown -R node:node /usr/src/app
|
||||||
USER node
|
USER node
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const dayLaborModel = require('../models/dayLaborModel');
|
const dayLaborModel = require('../models/dayLaborModel');
|
||||||
const { getPool } = require('../models/partnerModel');
|
const { getPool } = require('../models/partnerModel');
|
||||||
const notify = require('../utils/notifyHelper');
|
const notify = require('../shared/utils/notifyHelper');
|
||||||
|
|
||||||
// 일용직 요청 목록
|
// 일용직 요청 목록
|
||||||
async function list(req, res) {
|
async function list(req, res) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const workReportModel = require('../models/workReportModel');
|
const workReportModel = require('../models/workReportModel');
|
||||||
const checkinModel = require('../models/checkinModel');
|
const checkinModel = require('../models/checkinModel');
|
||||||
const ExcelJS = require('exceljs');
|
const ExcelJS = require('exceljs');
|
||||||
const notify = require('../utils/notifyHelper');
|
const notify = require('../shared/utils/notifyHelper');
|
||||||
|
|
||||||
// 작업보고 목록
|
// 작업보고 목록
|
||||||
async function list(req, res) {
|
async function list(req, res) {
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const mysql = require('mysql2/promise');
|
const { getPool } = require('../shared/config/database');
|
||||||
|
|
||||||
const JWT_SECRET = process.env.SSO_JWT_SECRET;
|
const JWT_SECRET = process.env.SSO_JWT_SECRET;
|
||||||
|
|
||||||
let pool;
|
|
||||||
function getPool() {
|
|
||||||
if (!pool) {
|
|
||||||
pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST || 'mariadb',
|
|
||||||
port: parseInt(process.env.DB_PORT) || 3306,
|
|
||||||
user: process.env.DB_USER || 'hyungi_user',
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME || 'hyungi',
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 5,
|
|
||||||
queueLimit: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractToken(req) {
|
function extractToken(req) {
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
|||||||
@@ -1,21 +1,4 @@
|
|||||||
const mysql = require('mysql2/promise');
|
const { getPool } = require('../shared/config/database');
|
||||||
|
|
||||||
let pool;
|
|
||||||
function getPool() {
|
|
||||||
if (!pool) {
|
|
||||||
pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST || 'mariadb',
|
|
||||||
port: parseInt(process.env.DB_PORT) || 3306,
|
|
||||||
user: process.env.DB_USER || 'hyungi_user',
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME || 'hyungi',
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 10,
|
|
||||||
queueLimit: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 협력업체 =====
|
// ===== 협력업체 =====
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
// utils/notifyHelper.js — 공용 알림 헬퍼
|
|
||||||
// system1-factory의 내부 알림 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,11 +1,20 @@
|
|||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY package*.json ./
|
|
||||||
|
# shared 모듈 복사
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
|
||||||
|
COPY tksafety/api/package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
COPY . .
|
|
||||||
|
COPY tksafety/api/ ./
|
||||||
|
|
||||||
RUN chown -R node:node /usr/src/app
|
RUN chown -R node:node /usr/src/app
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
||||||
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
|
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
|
||||||
|
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const visitRequestModel = require('../models/visitRequestModel');
|
const visitRequestModel = require('../models/visitRequestModel');
|
||||||
const notify = require('../utils/notifyHelper');
|
const notify = require('../shared/utils/notifyHelper');
|
||||||
|
|
||||||
// ==================== 출입 신청 관리 ====================
|
// ==================== 출입 신청 관리 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const mysql = require('mysql2/promise');
|
const { getPool } = require('../shared/config/database');
|
||||||
|
|
||||||
const JWT_SECRET = process.env.SSO_JWT_SECRET;
|
const JWT_SECRET = process.env.SSO_JWT_SECRET;
|
||||||
|
|
||||||
let pool;
|
|
||||||
function getPool() {
|
|
||||||
if (!pool) {
|
|
||||||
pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST || 'mariadb',
|
|
||||||
port: parseInt(process.env.DB_PORT) || 3306,
|
|
||||||
user: process.env.DB_USER || 'hyungi_user',
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME || 'hyungi',
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 5,
|
|
||||||
queueLimit: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractToken(req) {
|
function extractToken(req) {
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
|||||||
@@ -1,21 +1,4 @@
|
|||||||
const mysql = require('mysql2/promise');
|
const { getPool } = require('../shared/config/database');
|
||||||
|
|
||||||
let pool;
|
|
||||||
function getPool() {
|
|
||||||
if (!pool) {
|
|
||||||
pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST || 'mariadb',
|
|
||||||
port: parseInt(process.env.DB_PORT) || 3306,
|
|
||||||
user: process.env.DB_USER || 'hyungi_user',
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME || 'hyungi',
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 10,
|
|
||||||
queueLimit: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findToday() {
|
async function findToday() {
|
||||||
const db = getPool();
|
const db = getPool();
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
// utils/notifyHelper.js — 공용 알림 헬퍼
|
|
||||||
// system1-factory의 내부 알림 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,11 +1,20 @@
|
|||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY package*.json ./
|
|
||||||
|
# shared 모듈 복사
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
|
||||||
|
COPY tksupport/api/package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
COPY . .
|
|
||||||
|
COPY tksupport/api/ ./
|
||||||
|
|
||||||
RUN chown -R node:node /usr/src/app
|
RUN chown -R node:node /usr/src/app
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
||||||
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
|
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
|
||||||
|
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const mysql = require('mysql2/promise');
|
const { getPool } = require('../shared/config/database');
|
||||||
|
|
||||||
const JWT_SECRET = process.env.SSO_JWT_SECRET;
|
const JWT_SECRET = process.env.SSO_JWT_SECRET;
|
||||||
|
|
||||||
let pool;
|
|
||||||
function getPool() {
|
|
||||||
if (!pool) {
|
|
||||||
pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST || 'mariadb',
|
|
||||||
port: parseInt(process.env.DB_PORT) || 3306,
|
|
||||||
user: process.env.DB_USER || 'hyungi_user',
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME || 'hyungi',
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 5,
|
|
||||||
queueLimit: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractToken(req) {
|
function extractToken(req) {
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package*.json ./
|
# shared 모듈 복사
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
|
||||||
|
COPY user-management/api/package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
COPY . .
|
COPY user-management/api/ ./
|
||||||
|
|
||||||
RUN mkdir -p /usr/src/app/uploads/consumables && \
|
RUN mkdir -p /usr/src/app/uploads/consumables && \
|
||||||
chown -R node:node /usr/src/app
|
chown -R node:node /usr/src/app
|
||||||
|
|||||||
@@ -5,28 +5,10 @@
|
|||||||
* sso-auth-service/models/userModel.js 기반
|
* sso-auth-service/models/userModel.js 기반
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const mysql = require('mysql2/promise');
|
const { getPool } = require('../shared/config/database');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
let pool;
|
|
||||||
|
|
||||||
function getPool() {
|
|
||||||
if (!pool) {
|
|
||||||
pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST || 'mariadb',
|
|
||||||
port: parseInt(process.env.DB_PORT) || 3306,
|
|
||||||
user: process.env.DB_USER || 'hyungi_user',
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME || 'hyungi',
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 10,
|
|
||||||
queueLimit: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* pbkdf2_sha256 해시 검증 (passlib 호환)
|
* pbkdf2_sha256 해시 검증 (passlib 호환)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user