diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..abefd9f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.claude +**/.env +**/node_modules +**/logs +**/__pycache__ +**/.pytest_cache +**/venv +**/web/ +*.md +!shared/** +FEATURES.pdf diff --git a/docker-compose.yml b/docker-compose.yml index 8f8d521..d6d48d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,8 +80,8 @@ services: system1-api: build: - context: ./system1-factory/api - dockerfile: Dockerfile + context: . + dockerfile: system1-factory/api/Dockerfile container_name: tk-system1-api restart: unless-stopped ports: @@ -152,8 +152,8 @@ services: system2-api: build: - context: ./system2-report/api - dockerfile: Dockerfile + context: . + dockerfile: system2-report/api/Dockerfile container_name: tk-system2-api restart: unless-stopped ports: @@ -252,8 +252,8 @@ services: tkuser-api: build: - context: ./user-management/api - dockerfile: Dockerfile + context: . + dockerfile: user-management/api/Dockerfile container_name: tk-tkuser-api restart: unless-stopped ports: @@ -302,8 +302,8 @@ services: tkpurchase-api: build: - context: ./tkpurchase/api - dockerfile: Dockerfile + context: . + dockerfile: tkpurchase/api/Dockerfile container_name: tk-tkpurchase-api restart: unless-stopped ports: @@ -343,8 +343,8 @@ services: tksafety-api: build: - context: ./tksafety/api - dockerfile: Dockerfile + context: . + dockerfile: tksafety/api/Dockerfile container_name: tk-tksafety-api restart: unless-stopped ports: @@ -388,8 +388,8 @@ services: tksupport-api: build: - context: ./tksupport/api - dockerfile: Dockerfile + context: . + dockerfile: tksupport/api/Dockerfile container_name: tk-tksupport-api restart: unless-stopped ports: diff --git a/shared/config/database.js b/shared/config/database.js new file mode 100644 index 0000000..65fcea9 --- /dev/null +++ b/shared/config/database.js @@ -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 }; diff --git a/shared/middleware/auth.js b/shared/middleware/auth.js new file mode 100644 index 0000000..5b8a165 --- /dev/null +++ b/shared/middleware/auth.js @@ -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 +}; diff --git a/shared/utils/errors.js b/shared/utils/errors.js new file mode 100644 index 0000000..490908d --- /dev/null +++ b/shared/utils/errors.js @@ -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 +}; diff --git a/shared/utils/logger.js b/shared/utils/logger.js new file mode 100644 index 0000000..1269c03 --- /dev/null +++ b/shared/utils/logger.js @@ -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; diff --git a/system1-factory/api/utils/notifyHelper.js b/shared/utils/notifyHelper.js similarity index 97% rename from system1-factory/api/utils/notifyHelper.js rename to shared/utils/notifyHelper.js index 7708a9c..f088e75 100644 --- a/system1-factory/api/utils/notifyHelper.js +++ b/shared/utils/notifyHelper.js @@ -1,4 +1,4 @@ -// utils/notifyHelper.js — 공용 알림 헬퍼 +// shared/utils/notifyHelper.js — 공용 알림 헬퍼 // tkuser-api의 내부 알림 API를 통해 DB 저장 + Push 전송 const http = require('http'); diff --git a/system1-factory/api/Dockerfile b/system1-factory/api/Dockerfile index 45f8a08..4bbc9ac 100644 --- a/system1-factory/api/Dockerfile +++ b/system1-factory/api/Dockerfile @@ -1,36 +1,27 @@ -# Node.js 공식 이미지 사용 FROM node:18-alpine - -# 작업 디렉토리 설정 WORKDIR /usr/src/app -# 패키지 파일 복사 (캐싱 최적화) -COPY package*.json ./ +# shared 모듈 복사 +COPY shared/ ./shared/ -# 프로덕션 의존성만 설치 (sharp용 빌드 도구 포함) +# 패키지 파일 복사 + 프로덕션 의존성 설치 (sharp용 빌드 도구 포함) +COPY system1-factory/api/package*.json ./ RUN apk add --no-cache --virtual .build-deps python3 make g++ && \ npm install --omit=dev && \ npm install sharp && \ apk del .build-deps # 앱 소스 복사 -COPY . . +COPY system1-factory/api/ ./ -# 로그 디렉토리 생성 +# 로그/업로드 디렉토리 생성 RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests - -# 실행 권한 설정 RUN chown -R node:node /usr/src/app - -# 보안을 위해 non-root 사용자로 실행 USER node -# 포트 노출 EXPOSE 3005 -# 헬스체크 추가 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", "index.js"] \ No newline at end of file +CMD ["node", "index.js"] diff --git a/system1-factory/api/middlewares/auth.js b/system1-factory/api/middlewares/auth.js index a9d1257..5bbb77f 100644 --- a/system1-factory/api/middlewares/auth.js +++ b/system1-factory/api/middlewares/auth.js @@ -1,357 +1 @@ -/** - * 통합 인증/인가 미들웨어 - * - * 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 -}; +module.exports = require('../shared/middleware/auth'); diff --git a/system1-factory/api/models/equipmentModel.js b/system1-factory/api/models/equipmentModel.js index 12945b1..f3cc31d 100644 --- a/system1-factory/api/models/equipmentModel.js +++ b/system1-factory/api/models/equipmentModel.js @@ -1,6 +1,6 @@ // models/equipmentModel.js const { getDb } = require('../dbPool'); -const notifyHelper = require('../utils/notifyHelper'); +const notifyHelper = require('../shared/utils/notifyHelper'); const EquipmentModel = { // CREATE - 설비 생성 diff --git a/system1-factory/api/models/purchaseModel.js b/system1-factory/api/models/purchaseModel.js index afb845a..f9ada27 100644 --- a/system1-factory/api/models/purchaseModel.js +++ b/system1-factory/api/models/purchaseModel.js @@ -93,7 +93,7 @@ const PurchaseModel = { console.error('[purchase] 설비 자동 등록 실패:', err.message); // fire-and-forget: admin 알림 전송 - const notifyHelper = require('../utils/notifyHelper'); + const notifyHelper = require('../shared/utils/notifyHelper'); notifyHelper.send({ type: 'equipment', title: `설비 자동 등록 실패: ${purchaseData.item_name}`, diff --git a/system1-factory/api/utils/errors.js b/system1-factory/api/utils/errors.js index 490908d..69ae98d 100644 --- a/system1-factory/api/utils/errors.js +++ b/system1-factory/api/utils/errors.js @@ -1,186 +1 @@ -/** - * 커스텀 에러 클래스 - * - * 애플리케이션 전체에서 사용하는 표준화된 에러 클래스들 - * - * @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 -}; +module.exports = require('../shared/utils/errors'); diff --git a/system1-factory/api/utils/logger.js b/system1-factory/api/utils/logger.js index 4109a96..52da3a3 100644 --- a/system1-factory/api/utils/logger.js +++ b/system1-factory/api/utils/logger.js @@ -1,197 +1 @@ -/** - * 로깅 유틸리티 - * - * 애플리케이션 전체에서 사용하는 통합 로거 - * - * @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; +module.exports = require('../shared/utils/logger'); diff --git a/system2-report/api/Dockerfile b/system2-report/api/Dockerfile index 7725c4a..c900b6d 100644 --- a/system2-report/api/Dockerfile +++ b/system2-report/api/Dockerfile @@ -4,10 +4,13 @@ RUN apk add --no-cache imagemagick libheif imagemagick-heic imagemagick-jpeg WORKDIR /usr/src/app -COPY package*.json ./ +# shared 모듈 복사 +COPY shared/ ./shared/ + +COPY system2-report/api/package*.json ./ RUN npm install --omit=dev -COPY . . +COPY system2-report/api/ ./ RUN mkdir -p logs uploads RUN chown -R node:node /usr/src/app diff --git a/system2-report/api/controllers/workIssueController.js b/system2-report/api/controllers/workIssueController.js index 18e4061..67516b9 100644 --- a/system2-report/api/controllers/workIssueController.js +++ b/system2-report/api/controllers/workIssueController.js @@ -5,7 +5,7 @@ const workIssueModel = require('../models/workIssueModel'); const imageUploadService = require('../services/imageUploadService'); const mProjectService = require('../services/mProjectService'); -const notify = require('../utils/notifyHelper'); +const notify = require('../shared/utils/notifyHelper'); // ==================== 신고 카테고리 관리 ==================== diff --git a/system2-report/api/middlewares/auth.js b/system2-report/api/middlewares/auth.js index e956724..5bbb77f 100644 --- a/system2-report/api/middlewares/auth.js +++ b/system2-report/api/middlewares/auth.js @@ -1,357 +1 @@ -/** - * 통합 인증/인가 미들웨어 - * - * 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 -}; +module.exports = require('../shared/middleware/auth'); diff --git a/system2-report/api/utils/errors.js b/system2-report/api/utils/errors.js index 490908d..69ae98d 100644 --- a/system2-report/api/utils/errors.js +++ b/system2-report/api/utils/errors.js @@ -1,186 +1 @@ -/** - * 커스텀 에러 클래스 - * - * 애플리케이션 전체에서 사용하는 표준화된 에러 클래스들 - * - * @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 -}; +module.exports = require('../shared/utils/errors'); diff --git a/system2-report/api/utils/logger.js b/system2-report/api/utils/logger.js index 4109a96..52da3a3 100644 --- a/system2-report/api/utils/logger.js +++ b/system2-report/api/utils/logger.js @@ -1,197 +1 @@ -/** - * 로깅 유틸리티 - * - * 애플리케이션 전체에서 사용하는 통합 로거 - * - * @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; +module.exports = require('../shared/utils/logger'); diff --git a/system2-report/api/utils/notifyHelper.js b/system2-report/api/utils/notifyHelper.js deleted file mode 100644 index e9eb0b2..0000000 --- a/system2-report/api/utils/notifyHelper.js +++ /dev/null @@ -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; diff --git a/tkpurchase/api/Dockerfile b/tkpurchase/api/Dockerfile index 7f8276e..10abe3f 100644 --- a/tkpurchase/api/Dockerfile +++ b/tkpurchase/api/Dockerfile @@ -1,11 +1,13 @@ FROM node:18-alpine - WORKDIR /usr/src/app -COPY package*.json ./ +# shared 모듈 복사 +COPY shared/ ./shared/ + +COPY tkpurchase/api/package*.json ./ RUN npm install --omit=dev -COPY . . +COPY tkpurchase/api/ ./ RUN chown -R node:node /usr/src/app USER node diff --git a/tkpurchase/api/controllers/dayLaborController.js b/tkpurchase/api/controllers/dayLaborController.js index 0d2dd8a..c1c78d8 100644 --- a/tkpurchase/api/controllers/dayLaborController.js +++ b/tkpurchase/api/controllers/dayLaborController.js @@ -1,6 +1,6 @@ const dayLaborModel = require('../models/dayLaborModel'); const { getPool } = require('../models/partnerModel'); -const notify = require('../utils/notifyHelper'); +const notify = require('../shared/utils/notifyHelper'); // 일용직 요청 목록 async function list(req, res) { diff --git a/tkpurchase/api/controllers/workReportController.js b/tkpurchase/api/controllers/workReportController.js index 94f054c..417747d 100644 --- a/tkpurchase/api/controllers/workReportController.js +++ b/tkpurchase/api/controllers/workReportController.js @@ -1,7 +1,7 @@ const workReportModel = require('../models/workReportModel'); const checkinModel = require('../models/checkinModel'); const ExcelJS = require('exceljs'); -const notify = require('../utils/notifyHelper'); +const notify = require('../shared/utils/notifyHelper'); // 작업보고 목록 async function list(req, res) { diff --git a/tkpurchase/api/middleware/auth.js b/tkpurchase/api/middleware/auth.js index 35e7d74..dc8d52a 100644 --- a/tkpurchase/api/middleware/auth.js +++ b/tkpurchase/api/middleware/auth.js @@ -1,25 +1,8 @@ const jwt = require('jsonwebtoken'); -const mysql = require('mysql2/promise'); +const { getPool } = require('../shared/config/database'); 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) { const authHeader = req.headers['authorization']; if (authHeader && authHeader.startsWith('Bearer ')) { diff --git a/tkpurchase/api/models/partnerModel.js b/tkpurchase/api/models/partnerModel.js index 1be32ac..7878074 100644 --- a/tkpurchase/api/models/partnerModel.js +++ b/tkpurchase/api/models/partnerModel.js @@ -1,21 +1,4 @@ -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: 10, - queueLimit: 0 - }); - } - return pool; -} +const { getPool } = require('../shared/config/database'); // ===== 협력업체 ===== diff --git a/tkpurchase/api/utils/notifyHelper.js b/tkpurchase/api/utils/notifyHelper.js deleted file mode 100644 index e9eb0b2..0000000 --- a/tkpurchase/api/utils/notifyHelper.js +++ /dev/null @@ -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; diff --git a/tksafety/api/Dockerfile b/tksafety/api/Dockerfile index 7432d7c..320be08 100644 --- a/tksafety/api/Dockerfile +++ b/tksafety/api/Dockerfile @@ -1,11 +1,20 @@ FROM node:18-alpine WORKDIR /usr/src/app -COPY package*.json ./ + +# shared 모듈 복사 +COPY shared/ ./shared/ + +COPY tksafety/api/package*.json ./ RUN npm install --omit=dev -COPY . . + +COPY tksafety/api/ ./ + RUN chown -R node:node /usr/src/app USER node + EXPOSE 3000 + 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", "index.js"] diff --git a/tksafety/api/controllers/visitRequestController.js b/tksafety/api/controllers/visitRequestController.js index ebff1b4..1819b5e 100644 --- a/tksafety/api/controllers/visitRequestController.js +++ b/tksafety/api/controllers/visitRequestController.js @@ -1,5 +1,5 @@ const visitRequestModel = require('../models/visitRequestModel'); -const notify = require('../utils/notifyHelper'); +const notify = require('../shared/utils/notifyHelper'); // ==================== 출입 신청 관리 ==================== diff --git a/tksafety/api/middleware/auth.js b/tksafety/api/middleware/auth.js index c53e4c3..02edc4c 100644 --- a/tksafety/api/middleware/auth.js +++ b/tksafety/api/middleware/auth.js @@ -1,25 +1,8 @@ const jwt = require('jsonwebtoken'); -const mysql = require('mysql2/promise'); +const { getPool } = require('../shared/config/database'); 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) { const authHeader = req.headers['authorization']; if (authHeader && authHeader.startsWith('Bearer ')) { diff --git a/tksafety/api/models/dailyVisitModel.js b/tksafety/api/models/dailyVisitModel.js index 7780b8a..7965dda 100644 --- a/tksafety/api/models/dailyVisitModel.js +++ b/tksafety/api/models/dailyVisitModel.js @@ -1,21 +1,4 @@ -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: 10, - queueLimit: 0 - }); - } - return pool; -} +const { getPool } = require('../shared/config/database'); async function findToday() { const db = getPool(); diff --git a/tksafety/api/utils/notifyHelper.js b/tksafety/api/utils/notifyHelper.js deleted file mode 100644 index e9eb0b2..0000000 --- a/tksafety/api/utils/notifyHelper.js +++ /dev/null @@ -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; diff --git a/tksupport/api/Dockerfile b/tksupport/api/Dockerfile index 7432d7c..f809175 100644 --- a/tksupport/api/Dockerfile +++ b/tksupport/api/Dockerfile @@ -1,11 +1,20 @@ FROM node:18-alpine WORKDIR /usr/src/app -COPY package*.json ./ + +# shared 모듈 복사 +COPY shared/ ./shared/ + +COPY tksupport/api/package*.json ./ RUN npm install --omit=dev -COPY . . + +COPY tksupport/api/ ./ + RUN chown -R node:node /usr/src/app USER node + EXPOSE 3000 + 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", "index.js"] diff --git a/tksupport/api/middleware/auth.js b/tksupport/api/middleware/auth.js index 6e39cd5..5869260 100644 --- a/tksupport/api/middleware/auth.js +++ b/tksupport/api/middleware/auth.js @@ -1,25 +1,8 @@ const jwt = require('jsonwebtoken'); -const mysql = require('mysql2/promise'); +const { getPool } = require('../shared/config/database'); 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) { const authHeader = req.headers['authorization']; if (authHeader && authHeader.startsWith('Bearer ')) { diff --git a/user-management/api/Dockerfile b/user-management/api/Dockerfile index 2c554ea..a39e220 100644 --- a/user-management/api/Dockerfile +++ b/user-management/api/Dockerfile @@ -1,11 +1,13 @@ FROM node:18-alpine - WORKDIR /usr/src/app -COPY package*.json ./ +# shared 모듈 복사 +COPY shared/ ./shared/ + +COPY user-management/api/package*.json ./ RUN npm install --omit=dev -COPY . . +COPY user-management/api/ ./ RUN mkdir -p /usr/src/app/uploads/consumables && \ chown -R node:node /usr/src/app diff --git a/user-management/api/models/userModel.js b/user-management/api/models/userModel.js index 4d92dbe..297e215 100644 --- a/user-management/api/models/userModel.js +++ b/user-management/api/models/userModel.js @@ -5,28 +5,10 @@ * sso-auth-service/models/userModel.js 기반 */ -const mysql = require('mysql2/promise'); +const { getPool } = require('../shared/config/database'); const bcrypt = require('bcrypt'); 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 호환) */