feat: 3-System 분리 프로젝트 초기 코드 작성

TK-FB(공장관리+신고)와 M-Project(부적합관리)를 3개 독립 시스템으로
분리하기 위한 전체 코드 구조 작성.
- SSO 인증 서비스 (bcrypt + pbkdf2 이중 해시 지원)
- System 1: 공장관리 (TK-FB 기반, 신고 코드 제거)
- System 2: 신고 (TK-FB에서 workIssue 코드 추출)
- System 3: 부적합관리 (M-Project 기반)
- Gateway 포털 (path-based 라우팅)
- 통합 docker-compose.yml 및 배포 스크립트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:40:11 +09:00
commit 550633b89d
824 changed files with 1071683 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN mkdir -p logs uploads
RUN chown -R node:node /usr/src/app
USER node
EXPOSE 3005
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --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"]

View File

@@ -0,0 +1,79 @@
/**
* 데이터베이스 연결 설정
*
* MySQL/MariaDB 커넥션 풀 관리
* - 환경 변수 기반 설정
* - 자동 재연결 (최대 5회 재시도)
* - UTF-8MB4 문자셋 지원
*
* @author TK-FB-Project
* @since 2025-12-11
*/
require('dotenv').config();
const mysql = require('mysql2/promise');
const retry = require('async-retry');
const logger = require('../utils/logger');
let pool = null;
async function initPool() {
if (pool) return pool;
const {
DB_HOST, DB_PORT, DB_USER,
DB_PASSWORD, DB_NAME,
DB_SOCKET, DB_CONN_LIMIT = '10'
} = process.env;
if (!DB_USER || !DB_PASSWORD || !DB_NAME) {
throw new Error('필수 환경변수(DB_USER, DB_PASSWORD, DB_NAME)가 없습니다.');
}
if (!DB_SOCKET && !DB_HOST) {
throw new Error('DB_SOCKET이 없으면 DB_HOST가 반드시 필요합니다.');
}
await retry(async () => {
const config = {
user: DB_USER,
password: DB_PASSWORD,
database: DB_NAME,
waitForConnections: true,
connectionLimit: parseInt(DB_CONN_LIMIT, 10),
queueLimit: 0,
charset: 'utf8mb4'
};
if (DB_SOCKET) {
config.socketPath = DB_SOCKET;
} else {
config.host = DB_HOST;
config.port = parseInt(DB_PORT, 10);
}
pool = mysql.createPool(config);
// 첫 연결 검증
const conn = await pool.getConnection();
await conn.query('SET NAMES utf8mb4');
conn.release();
const connectionInfo = DB_SOCKET ? `socket=${DB_SOCKET}` : `${DB_HOST}:${DB_PORT}`;
logger.info('MariaDB 연결 성공', {
connection: connectionInfo,
database: DB_NAME,
connectionLimit: parseInt(DB_CONN_LIMIT, 10)
});
}, {
retries: 5,
factor: 2,
minTimeout: 1000
});
return pool;
}
async function getDb() {
return initPool();
}
module.exports = { getDb };

View File

@@ -0,0 +1,674 @@
/**
* 작업 중 문제 신고 컨트롤러
*/
const workIssueModel = require('../models/workIssueModel');
const imageUploadService = require('../services/imageUploadService');
// ==================== 신고 카테고리 관리 ====================
/**
* 모든 카테고리 조회
*/
exports.getAllCategories = (req, res) => {
workIssueModel.getAllCategories((err, categories) => {
if (err) {
console.error('카테고리 조회 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 조회 실패' });
}
res.json({ success: true, data: categories });
});
};
/**
* 타입별 카테고리 조회
*/
exports.getCategoriesByType = (req, res) => {
const { type } = req.params;
if (!['nonconformity', 'safety'].includes(type)) {
return res.status(400).json({ success: false, error: '유효하지 않은 카테고리 타입입니다.' });
}
workIssueModel.getCategoriesByType(type, (err, categories) => {
if (err) {
console.error('카테고리 조회 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 조회 실패' });
}
res.json({ success: true, data: categories });
});
};
/**
* 카테고리 생성
*/
exports.createCategory = (req, res) => {
const { category_type, category_name, description, display_order } = req.body;
if (!category_type || !category_name) {
return res.status(400).json({ success: false, error: '카테고리 타입과 이름은 필수입니다.' });
}
workIssueModel.createCategory(
{ category_type, category_name, description, display_order },
(err, categoryId) => {
if (err) {
console.error('카테고리 생성 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 생성 실패' });
}
res.status(201).json({
success: true,
message: '카테고리가 생성되었습니다.',
data: { category_id: categoryId }
});
}
);
};
/**
* 카테고리 수정
*/
exports.updateCategory = (req, res) => {
const { id } = req.params;
const { category_name, description, display_order, is_active } = req.body;
workIssueModel.updateCategory(
id,
{ category_name, description, display_order, is_active },
(err, result) => {
if (err) {
console.error('카테고리 수정 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 수정 실패' });
}
res.json({ success: true, message: '카테고리가 수정되었습니다.' });
}
);
};
/**
* 카테고리 삭제
*/
exports.deleteCategory = (req, res) => {
const { id } = req.params;
workIssueModel.deleteCategory(id, (err, result) => {
if (err) {
console.error('카테고리 삭제 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 삭제 실패' });
}
res.json({ success: true, message: '카테고리가 삭제되었습니다.' });
});
};
// ==================== 사전 정의 항목 관리 ====================
/**
* 카테고리별 항목 조회
*/
exports.getItemsByCategory = (req, res) => {
const { categoryId } = req.params;
workIssueModel.getItemsByCategory(categoryId, (err, items) => {
if (err) {
console.error('항목 조회 실패:', err);
return res.status(500).json({ success: false, error: '항목 조회 실패' });
}
res.json({ success: true, data: items });
});
};
/**
* 모든 항목 조회
*/
exports.getAllItems = (req, res) => {
workIssueModel.getAllItems((err, items) => {
if (err) {
console.error('항목 조회 실패:', err);
return res.status(500).json({ success: false, error: '항목 조회 실패' });
}
res.json({ success: true, data: items });
});
};
/**
* 항목 생성
*/
exports.createItem = (req, res) => {
const { category_id, item_name, description, severity, display_order } = req.body;
if (!category_id || !item_name) {
return res.status(400).json({ success: false, error: '카테고리 ID와 항목명은 필수입니다.' });
}
workIssueModel.createItem(
{ category_id, item_name, description, severity, display_order },
(err, itemId) => {
if (err) {
console.error('항목 생성 실패:', err);
return res.status(500).json({ success: false, error: '항목 생성 실패' });
}
res.status(201).json({
success: true,
message: '항목이 생성되었습니다.',
data: { item_id: itemId }
});
}
);
};
/**
* 항목 수정
*/
exports.updateItem = (req, res) => {
const { id } = req.params;
const { item_name, description, severity, display_order, is_active } = req.body;
workIssueModel.updateItem(
id,
{ item_name, description, severity, display_order, is_active },
(err, result) => {
if (err) {
console.error('항목 수정 실패:', err);
return res.status(500).json({ success: false, error: '항목 수정 실패' });
}
res.json({ success: true, message: '항목이 수정되었습니다.' });
}
);
};
/**
* 항목 삭제
*/
exports.deleteItem = (req, res) => {
const { id } = req.params;
workIssueModel.deleteItem(id, (err, result) => {
if (err) {
console.error('항목 삭제 실패:', err);
return res.status(500).json({ success: false, error: '항목 삭제 실패' });
}
res.json({ success: true, message: '항목이 삭제되었습니다.' });
});
};
// ==================== 문제 신고 관리 ====================
/**
* 신고 생성
*/
exports.createReport = async (req, res) => {
try {
const {
factory_category_id,
workplace_id,
custom_location,
tbm_session_id,
visit_request_id,
issue_category_id,
issue_item_id,
custom_item_name, // 직접 입력한 항목명
additional_description,
photos = []
} = req.body;
const reporter_id = req.user.user_id;
if (!issue_category_id) {
return res.status(400).json({ success: false, error: '신고 카테고리는 필수입니다.' });
}
// 위치 정보 검증 (지도 선택 또는 기타 위치)
if (!factory_category_id && !custom_location) {
return res.status(400).json({ success: false, error: '위치 정보는 필수입니다.' });
}
// 항목 검증 (기존 항목 또는 직접 입력)
if (!issue_item_id && !custom_item_name) {
return res.status(400).json({ success: false, error: '신고 항목은 필수입니다.' });
}
// 직접 입력한 항목이 있으면 DB에 저장
let finalItemId = issue_item_id;
if (custom_item_name && !issue_item_id) {
try {
finalItemId = await new Promise((resolve, reject) => {
workIssueModel.createItem(
{
category_id: issue_category_id,
item_name: custom_item_name,
description: '사용자 직접 입력',
severity: 'medium',
display_order: 999 // 마지막에 표시
},
(err, itemId) => {
if (err) reject(err);
else resolve(itemId);
}
);
});
} catch (itemErr) {
console.error('커스텀 항목 생성 실패:', itemErr);
return res.status(500).json({ success: false, error: '항목 저장 실패' });
}
}
// 사진 저장 (최대 5장)
const photoPaths = {
photo_path1: null,
photo_path2: null,
photo_path3: null,
photo_path4: null,
photo_path5: null
};
for (let i = 0; i < Math.min(photos.length, 5); i++) {
if (photos[i]) {
const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue');
if (savedPath) {
photoPaths[`photo_path${i + 1}`] = savedPath;
}
}
}
const reportData = {
reporter_id,
factory_category_id: factory_category_id || null,
workplace_id: workplace_id || null,
custom_location: custom_location || null,
tbm_session_id: tbm_session_id || null,
visit_request_id: visit_request_id || null,
issue_category_id,
issue_item_id: finalItemId || null,
additional_description: additional_description || null,
...photoPaths
};
workIssueModel.createReport(reportData, (err, reportId) => {
if (err) {
console.error('신고 생성 실패:', err);
return res.status(500).json({ success: false, error: '신고 생성 실패' });
}
res.status(201).json({
success: true,
message: '문제 신고가 등록되었습니다.',
data: { report_id: reportId }
});
});
} catch (error) {
console.error('신고 생성 에러:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
}
};
/**
* 신고 목록 조회
*/
exports.getAllReports = (req, res) => {
const filters = {
status: req.query.status,
category_type: req.query.category_type,
issue_category_id: req.query.issue_category_id,
factory_category_id: req.query.factory_category_id,
workplace_id: req.query.workplace_id,
assigned_user_id: req.query.assigned_user_id,
start_date: req.query.start_date,
end_date: req.query.end_date,
search: req.query.search,
limit: req.query.limit,
offset: req.query.offset
};
// 일반 사용자는 자신의 신고만 조회 (관리자 제외)
const userLevel = req.user.access_level;
if (!['admin', 'system', 'support_team'].includes(userLevel)) {
filters.reporter_id = req.user.user_id;
}
workIssueModel.getAllReports(filters, (err, reports) => {
if (err) {
console.error('신고 목록 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 목록 조회 실패' });
}
res.json({ success: true, data: reports });
});
};
/**
* 신고 상세 조회
*/
exports.getReportById = (req, res) => {
const { id } = req.params;
workIssueModel.getReportById(id, (err, report) => {
if (err) {
console.error('신고 상세 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 상세 조회 실패' });
}
if (!report) {
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
}
// 권한 확인: 본인, 담당자, 또는 관리자
const userLevel = req.user.access_level;
const isOwner = report.reporter_id === req.user.user_id;
const isAssignee = report.assigned_user_id === req.user.user_id;
const isManager = ['admin', 'system', 'support_team'].includes(userLevel);
if (!isOwner && !isAssignee && !isManager) {
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
}
res.json({ success: true, data: report });
});
};
/**
* 신고 수정
*/
exports.updateReport = async (req, res) => {
try {
const { id } = req.params;
// 기존 신고 확인
workIssueModel.getReportById(id, async (err, report) => {
if (err) {
console.error('신고 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 조회 실패' });
}
if (!report) {
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
}
// 권한 확인
const userLevel = req.user.access_level;
const isOwner = report.reporter_id === req.user.user_id;
const isManager = ['admin', 'system'].includes(userLevel);
if (!isOwner && !isManager) {
return res.status(403).json({ success: false, error: '수정 권한이 없습니다.' });
}
// 상태 확인: reported 상태에서만 수정 가능 (관리자 제외)
if (!isManager && report.status !== 'reported') {
return res.status(400).json({ success: false, error: '이미 접수된 신고는 수정할 수 없습니다.' });
}
const {
factory_category_id,
workplace_id,
custom_location,
issue_category_id,
issue_item_id,
additional_description,
photos = []
} = req.body;
// 사진 업데이트 처리
const photoPaths = {};
for (let i = 0; i < Math.min(photos.length, 5); i++) {
if (photos[i]) {
// 기존 사진 삭제
const oldPath = report[`photo_path${i + 1}`];
if (oldPath) {
await imageUploadService.deleteFile(oldPath);
}
// 새 사진 저장
const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue');
if (savedPath) {
photoPaths[`photo_path${i + 1}`] = savedPath;
}
}
}
const updateData = {
factory_category_id,
workplace_id,
custom_location,
issue_category_id,
issue_item_id,
additional_description,
...photoPaths
};
workIssueModel.updateReport(id, updateData, req.user.user_id, (updateErr, result) => {
if (updateErr) {
console.error('신고 수정 실패:', updateErr);
return res.status(500).json({ success: false, error: '신고 수정 실패' });
}
res.json({ success: true, message: '신고가 수정되었습니다.' });
});
});
} catch (error) {
console.error('신고 수정 에러:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
}
};
/**
* 신고 삭제
*/
exports.deleteReport = async (req, res) => {
const { id } = req.params;
workIssueModel.getReportById(id, async (err, report) => {
if (err) {
console.error('신고 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 조회 실패' });
}
if (!report) {
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
}
// 권한 확인
const userLevel = req.user.access_level;
const isOwner = report.reporter_id === req.user.user_id;
const isManager = ['admin', 'system'].includes(userLevel);
if (!isOwner && !isManager) {
return res.status(403).json({ success: false, error: '삭제 권한이 없습니다.' });
}
workIssueModel.deleteReport(id, async (deleteErr, { result, photos }) => {
if (deleteErr) {
console.error('신고 삭제 실패:', deleteErr);
return res.status(500).json({ success: false, error: '신고 삭제 실패' });
}
// 사진 파일 삭제
if (photos) {
const allPhotos = [
photos.photo_path1, photos.photo_path2, photos.photo_path3,
photos.photo_path4, photos.photo_path5,
photos.resolution_photo_path1, photos.resolution_photo_path2
].filter(Boolean);
await imageUploadService.deleteMultipleFiles(allPhotos);
}
res.json({ success: true, message: '신고가 삭제되었습니다.' });
});
});
};
// ==================== 상태 관리 ====================
/**
* 신고 접수
*/
exports.receiveReport = (req, res) => {
const { id } = req.params;
workIssueModel.receiveReport(id, req.user.user_id, (err, result) => {
if (err) {
console.error('신고 접수 실패:', err);
return res.status(400).json({ success: false, error: err.message || '신고 접수 실패' });
}
res.json({ success: true, message: '신고가 접수되었습니다.' });
});
};
/**
* 담당자 배정
*/
exports.assignReport = (req, res) => {
const { id } = req.params;
const { assigned_department, assigned_user_id } = req.body;
if (!assigned_user_id) {
return res.status(400).json({ success: false, error: '담당자는 필수입니다.' });
}
workIssueModel.assignReport(id, {
assigned_department,
assigned_user_id,
assigned_by: req.user.user_id
}, (err, result) => {
if (err) {
console.error('담당자 배정 실패:', err);
return res.status(400).json({ success: false, error: err.message || '담당자 배정 실패' });
}
res.json({ success: true, message: '담당자가 배정되었습니다.' });
});
};
/**
* 처리 시작
*/
exports.startProcessing = (req, res) => {
const { id } = req.params;
workIssueModel.startProcessing(id, req.user.user_id, (err, result) => {
if (err) {
console.error('처리 시작 실패:', err);
return res.status(400).json({ success: false, error: err.message || '처리 시작 실패' });
}
res.json({ success: true, message: '처리가 시작되었습니다.' });
});
};
/**
* 처리 완료
*/
exports.completeReport = async (req, res) => {
try {
const { id } = req.params;
const { resolution_notes, resolution_photos = [] } = req.body;
// 완료 사진 저장
let resolution_photo_path1 = null;
let resolution_photo_path2 = null;
if (resolution_photos[0]) {
resolution_photo_path1 = await imageUploadService.saveBase64Image(resolution_photos[0], 'resolution');
}
if (resolution_photos[1]) {
resolution_photo_path2 = await imageUploadService.saveBase64Image(resolution_photos[1], 'resolution');
}
workIssueModel.completeReport(id, {
resolution_notes,
resolution_photo_path1,
resolution_photo_path2,
resolved_by: req.user.user_id
}, (err, result) => {
if (err) {
console.error('처리 완료 실패:', err);
return res.status(400).json({ success: false, error: err.message || '처리 완료 실패' });
}
res.json({ success: true, message: '처리가 완료되었습니다.' });
});
} catch (error) {
console.error('처리 완료 에러:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
}
};
/**
* 신고 종료
*/
exports.closeReport = (req, res) => {
const { id } = req.params;
workIssueModel.closeReport(id, req.user.user_id, (err, result) => {
if (err) {
console.error('신고 종료 실패:', err);
return res.status(400).json({ success: false, error: err.message || '신고 종료 실패' });
}
res.json({ success: true, message: '신고가 종료되었습니다.' });
});
};
/**
* 상태 변경 이력 조회
*/
exports.getStatusLogs = (req, res) => {
const { id } = req.params;
workIssueModel.getStatusLogs(id, (err, logs) => {
if (err) {
console.error('상태 이력 조회 실패:', err);
return res.status(500).json({ success: false, error: '상태 이력 조회 실패' });
}
res.json({ success: true, data: logs });
});
};
// ==================== 통계 ====================
/**
* 통계 요약
*/
exports.getStatsSummary = (req, res) => {
const filters = {
start_date: req.query.start_date,
end_date: req.query.end_date,
factory_category_id: req.query.factory_category_id
};
workIssueModel.getStatsSummary(filters, (err, stats) => {
if (err) {
console.error('통계 조회 실패:', err);
return res.status(500).json({ success: false, error: '통계 조회 실패' });
}
res.json({ success: true, data: stats });
});
};
/**
* 카테고리별 통계
*/
exports.getStatsByCategory = (req, res) => {
const filters = {
start_date: req.query.start_date,
end_date: req.query.end_date
};
workIssueModel.getStatsByCategory(filters, (err, stats) => {
if (err) {
console.error('카테고리별 통계 조회 실패:', err);
return res.status(500).json({ success: false, error: '통계 조회 실패' });
}
res.json({ success: true, data: stats });
});
};
/**
* 작업장별 통계
*/
exports.getStatsByWorkplace = (req, res) => {
const filters = {
start_date: req.query.start_date,
end_date: req.query.end_date,
factory_category_id: req.query.factory_category_id
};
workIssueModel.getStatsByWorkplace(filters, (err, stats) => {
if (err) {
console.error('작업장별 통계 조회 실패:', err);
return res.status(500).json({ success: false, error: '통계 조회 실패' });
}
res.json({ success: true, data: stats });
});
};

View File

@@ -0,0 +1,11 @@
/**
* 데이터베이스 풀 (호환성 레거시 파일)
*
* @deprecated 이 파일은 하위 호환성을 위해 유지됩니다.
* 새로운 코드에서는 './config/database'를 직접 import하세요.
*
* @author TK-FB-Project
* @since 2025-12-11
*/
module.exports = require('./config/database');

View File

@@ -0,0 +1,99 @@
/**
* System 2 - 신고 시스템 API Server
*
* TK-FB-Project에서 추출한 workIssue(신고) 전용 서비스
*/
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const path = require('path');
const logger = require('./utils/logger');
const { AuthenticationError, ForbiddenError } = require('./utils/errors');
const app = express();
const PORT = process.env.PORT || 3005;
app.set('trust proxy', 1);
// CORS
app.use(cors({
origin: true,
credentials: true
}));
// Body parser
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Static files (uploads)
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// Rate limiter
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 500,
message: 'API 요청 한도를 초과했습니다.',
standardHeaders: true,
legacyHeaders: false
});
app.use('/api/', apiLimiter);
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', service: 'system2-report', timestamp: new Date().toISOString() });
});
// JWT Auth middleware (SSO 공유 시크릿)
const jwt = require('jsonwebtoken');
const requireAuth = (req, res, next) => {
try {
const authHeader = req.headers['authorization'];
if (!authHeader) {
throw new AuthenticationError('Authorization 헤더가 필요합니다');
}
const token = authHeader.split(' ')[1];
if (!token) {
throw new AuthenticationError('Bearer 토큰이 필요합니다');
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' });
}
if (err instanceof AuthenticationError) {
return res.status(401).json({ success: false, error: err.message });
}
next(err);
}
};
// Routes
const workIssueRoutes = require('./routes/workIssueRoutes');
// 인증이 필요한 API
app.use('/api/work-issues', requireAuth, workIssueRoutes);
// Error handler
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
logger.error('API Error:', { error: err.message, path: req.path });
res.status(statusCode).json({
success: false,
error: err.message || 'Internal Server Error'
});
});
// 404
app.use((req, res) => {
res.status(404).json({ success: false, error: 'Not Found', path: req.originalUrl });
});
app.listen(PORT, () => {
console.log(`System 2 (신고) API running on port ${PORT}`);
});
module.exports = app;

View File

@@ -0,0 +1,357 @@
/**
* 통합 인증/인가 미들웨어
*
* 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
};

View File

@@ -0,0 +1,887 @@
/**
* 작업 중 문제 신고 모델
* 부적합/안전 신고 관련 DB 쿼리
*/
const { getDb } = require('../dbPool');
// ==================== 신고 카테고리 관리 ====================
/**
* 모든 신고 카테고리 조회
*/
const getAllCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_type, category_name, description, display_order, is_active, created_at
FROM issue_report_categories
ORDER BY category_type, display_order, category_id`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 타입별 활성 카테고리 조회 (nonconformity/safety)
*/
const getCategoriesByType = async (categoryType, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_type, category_name, description, display_order
FROM issue_report_categories
WHERE category_type = ? AND is_active = TRUE
ORDER BY display_order, category_id`,
[categoryType]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 생성
*/
const createCategory = async (categoryData, callback) => {
try {
const db = await getDb();
const { category_type, category_name, description = null, display_order = 0 } = categoryData;
const [result] = await db.query(
`INSERT INTO issue_report_categories (category_type, category_name, description, display_order)
VALUES (?, ?, ?, ?)`,
[category_type, category_name, description, display_order]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 수정
*/
const updateCategory = async (categoryId, categoryData, callback) => {
try {
const db = await getDb();
const { category_name, description, display_order, is_active } = categoryData;
const [result] = await db.query(
`UPDATE issue_report_categories
SET category_name = ?, description = ?, display_order = ?, is_active = ?
WHERE category_id = ?`,
[category_name, description, display_order, is_active, categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 삭제
*/
const deleteCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM issue_report_categories WHERE category_id = ?`,
[categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 사전 정의 신고 항목 관리 ====================
/**
* 카테고리별 활성 항목 조회
*/
const getItemsByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT item_id, category_id, item_name, description, severity, display_order
FROM issue_report_items
WHERE category_id = ? AND is_active = TRUE
ORDER BY display_order, item_id`,
[categoryId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 모든 항목 조회 (관리용)
*/
const getAllItems = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT iri.item_id, iri.category_id, iri.item_name, iri.description,
iri.severity, iri.display_order, iri.is_active, iri.created_at,
irc.category_name, irc.category_type
FROM issue_report_items iri
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
ORDER BY irc.category_type, irc.display_order, iri.display_order, iri.item_id`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 항목 생성
*/
const createItem = async (itemData, callback) => {
try {
const db = await getDb();
const { category_id, item_name, description = null, severity = 'medium', display_order = 0 } = itemData;
const [result] = await db.query(
`INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order)
VALUES (?, ?, ?, ?, ?)`,
[category_id, item_name, description, severity, display_order]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 항목 수정
*/
const updateItem = async (itemId, itemData, callback) => {
try {
const db = await getDb();
const { item_name, description, severity, display_order, is_active } = itemData;
const [result] = await db.query(
`UPDATE issue_report_items
SET item_name = ?, description = ?, severity = ?, display_order = ?, is_active = ?
WHERE item_id = ?`,
[item_name, description, severity, display_order, is_active, itemId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 항목 삭제
*/
const deleteItem = async (itemId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM issue_report_items WHERE item_id = ?`,
[itemId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 문제 신고 관리 ====================
// 한국 시간 유틸리티 import
const { getKoreaDatetime } = require('../utils/dateUtils');
/**
* 신고 생성
*/
const createReport = async (reportData, callback) => {
try {
const db = await getDb();
const {
reporter_id,
factory_category_id = null,
workplace_id = null,
custom_location = null,
tbm_session_id = null,
visit_request_id = null,
issue_category_id,
issue_item_id = null,
additional_description = null,
photo_path1 = null,
photo_path2 = null,
photo_path3 = null,
photo_path4 = null,
photo_path5 = null
} = reportData;
// 한국 시간 기준으로 신고 일시 설정
const reportDate = getKoreaDatetime();
const [result] = await db.query(
`INSERT INTO work_issue_reports
(reporter_id, report_date, factory_category_id, workplace_id, custom_location,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[reporter_id, reportDate, factory_category_id, workplace_id, custom_location,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5]
);
// 상태 변경 로그 기록
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, NULL, 'reported', ?)`,
[result.insertId, reporter_id]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 신고 목록 조회 (필터 옵션 포함)
*/
const getAllReports = async (filters = {}, callback) => {
try {
const db = await getDb();
let query = `
SELECT
wir.report_id, wir.reporter_id, wir.report_date,
wir.factory_category_id, wir.workplace_id, wir.custom_location,
wir.tbm_session_id, wir.visit_request_id,
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at,
wir.resolution_notes, wir.resolved_at,
wir.created_at, wir.updated_at,
u.username as reporter_name, u.name as reporter_full_name,
wc.category_name as factory_name,
w.workplace_name,
irc.category_type, irc.category_name as issue_category_name,
iri.item_name as issue_item_name, iri.severity,
assignee.username as assigned_user_name, assignee.name as assigned_full_name
FROM work_issue_reports wir
INNER JOIN users u ON wir.reporter_id = u.user_id
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
WHERE 1=1
`;
const params = [];
// 필터 적용
if (filters.status) {
query += ` AND wir.status = ?`;
params.push(filters.status);
}
if (filters.category_type) {
query += ` AND irc.category_type = ?`;
params.push(filters.category_type);
}
if (filters.issue_category_id) {
query += ` AND wir.issue_category_id = ?`;
params.push(filters.issue_category_id);
}
if (filters.factory_category_id) {
query += ` AND wir.factory_category_id = ?`;
params.push(filters.factory_category_id);
}
if (filters.workplace_id) {
query += ` AND wir.workplace_id = ?`;
params.push(filters.workplace_id);
}
if (filters.reporter_id) {
query += ` AND wir.reporter_id = ?`;
params.push(filters.reporter_id);
}
if (filters.assigned_user_id) {
query += ` AND wir.assigned_user_id = ?`;
params.push(filters.assigned_user_id);
}
if (filters.start_date && filters.end_date) {
query += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.search) {
query += ` AND (wir.additional_description LIKE ? OR iri.item_name LIKE ? OR wir.custom_location LIKE ?)`;
const searchTerm = `%${filters.search}%`;
params.push(searchTerm, searchTerm, searchTerm);
}
query += ` ORDER BY wir.report_date DESC, wir.report_id DESC`;
// 페이지네이션
if (filters.limit) {
query += ` LIMIT ?`;
params.push(parseInt(filters.limit));
if (filters.offset) {
query += ` OFFSET ?`;
params.push(parseInt(filters.offset));
}
}
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 신고 상세 조회
*/
const getReportById = async (reportId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT
wir.report_id, wir.reporter_id, wir.report_date,
wir.factory_category_id, wir.workplace_id, wir.custom_location,
wir.tbm_session_id, wir.visit_request_id,
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at, wir.assigned_by,
wir.resolution_notes, wir.resolution_photo_path1, wir.resolution_photo_path2,
wir.resolved_at, wir.resolved_by,
wir.modification_history,
wir.created_at, wir.updated_at,
u.username as reporter_name, u.name as reporter_full_name,
wc.category_name as factory_name,
w.workplace_name,
irc.category_type, irc.category_name as issue_category_name,
iri.item_name as issue_item_name, iri.severity,
assignee.username as assigned_user_name, assignee.name as assigned_full_name,
assigner.username as assigned_by_name,
resolver.username as resolved_by_name
FROM work_issue_reports wir
INNER JOIN users u ON wir.reporter_id = u.user_id
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
LEFT JOIN users assigner ON wir.assigned_by = assigner.user_id
LEFT JOIN users resolver ON wir.resolved_by = resolver.user_id
WHERE wir.report_id = ?`,
[reportId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 신고 수정
*/
const updateReport = async (reportId, reportData, userId, callback) => {
try {
const db = await getDb();
// 기존 데이터 조회
const [existing] = await db.query(
`SELECT * FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (existing.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
const current = existing[0];
// 수정 이력 생성
const modifications = [];
const now = new Date().toISOString();
for (const key of Object.keys(reportData)) {
if (current[key] !== reportData[key] && reportData[key] !== undefined) {
modifications.push({
field: key,
old_value: current[key],
new_value: reportData[key],
modified_at: now,
modified_by: userId
});
}
}
// 기존 이력과 병합
const existingHistory = current.modification_history ? JSON.parse(current.modification_history) : [];
const newHistory = [...existingHistory, ...modifications];
const {
factory_category_id,
workplace_id,
custom_location,
issue_category_id,
issue_item_id,
additional_description,
photo_path1,
photo_path2,
photo_path3,
photo_path4,
photo_path5
} = reportData;
const [result] = await db.query(
`UPDATE work_issue_reports
SET factory_category_id = COALESCE(?, factory_category_id),
workplace_id = COALESCE(?, workplace_id),
custom_location = COALESCE(?, custom_location),
issue_category_id = COALESCE(?, issue_category_id),
issue_item_id = COALESCE(?, issue_item_id),
additional_description = COALESCE(?, additional_description),
photo_path1 = COALESCE(?, photo_path1),
photo_path2 = COALESCE(?, photo_path2),
photo_path3 = COALESCE(?, photo_path3),
photo_path4 = COALESCE(?, photo_path4),
photo_path5 = COALESCE(?, photo_path5),
modification_history = ?,
updated_at = NOW()
WHERE report_id = ?`,
[factory_category_id, workplace_id, custom_location,
issue_category_id, issue_item_id, additional_description,
photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
JSON.stringify(newHistory), reportId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 신고 삭제
*/
const deleteReport = async (reportId, callback) => {
try {
const db = await getDb();
// 먼저 사진 경로 조회 (삭제용)
const [photos] = await db.query(
`SELECT photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
resolution_photo_path1, resolution_photo_path2
FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
const [result] = await db.query(
`DELETE FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
// 삭제할 사진 경로 반환
callback(null, { result, photos: photos[0] });
} catch (err) {
callback(err);
}
};
// ==================== 상태 관리 ====================
/**
* 신고 접수 (reported → received)
*/
const receiveReport = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'reported') {
return callback(new Error('접수 대기 상태가 아닙니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'received', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'reported', 'received', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 담당자 배정
*/
const assignReport = async (reportId, assignData, callback) => {
try {
const db = await getDb();
const { assigned_department, assigned_user_id, assigned_by } = assignData;
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
// 접수 상태 이상이어야 배정 가능
const validStatuses = ['received', 'in_progress'];
if (!validStatuses.includes(current[0].status)) {
return callback(new Error('접수된 상태에서만 담당자 배정이 가능합니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET assigned_department = ?, assigned_user_id = ?,
assigned_at = NOW(), assigned_by = ?, updated_at = NOW()
WHERE report_id = ?`,
[assigned_department, assigned_user_id, assigned_by, reportId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 처리 시작 (received → in_progress)
*/
const startProcessing = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'received') {
return callback(new Error('접수된 상태에서만 처리를 시작할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'in_progress', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'received', 'in_progress', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 처리 완료 (in_progress → completed)
*/
const completeReport = async (reportId, completionData, callback) => {
try {
const db = await getDb();
const { resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by } = completionData;
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'in_progress') {
return callback(new Error('처리 중 상태에서만 완료할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'completed', resolution_notes = ?,
resolution_photo_path1 = ?, resolution_photo_path2 = ?,
resolved_at = NOW(), resolved_by = ?, updated_at = NOW()
WHERE report_id = ?`,
[resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by, reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by, change_reason)
VALUES (?, 'in_progress', 'completed', ?, ?)`,
[reportId, resolved_by, resolution_notes]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 신고 종료 (completed → closed)
*/
const closeReport = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'completed') {
return callback(new Error('완료된 상태에서만 종료할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'closed', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'completed', 'closed', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 상태 변경 이력 조회
*/
const getStatusLogs = async (reportId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT wisl.log_id, wisl.report_id, wisl.previous_status, wisl.new_status,
wisl.changed_by, wisl.change_reason, wisl.changed_at,
u.username as changed_by_name, u.name as changed_by_full_name
FROM work_issue_status_logs wisl
INNER JOIN users u ON wisl.changed_by = u.user_id
WHERE wisl.report_id = ?
ORDER BY wisl.changed_at ASC`,
[reportId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
// ==================== 통계 ====================
/**
* 신고 통계 요약
*/
const getStatsSummary = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = '1=1';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.factory_category_id) {
whereClause += ` AND factory_category_id = ?`;
params.push(filters.factory_category_id);
}
const [rows] = await db.query(
`SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'reported' THEN 1 ELSE 0 END) as reported,
SUM(CASE WHEN status = 'received' THEN 1 ELSE 0 END) as received,
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed
FROM work_issue_reports
WHERE ${whereClause}`,
params
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 카테고리별 통계
*/
const getStatsByCategory = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = '1=1';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
const [rows] = await db.query(
`SELECT
irc.category_type, irc.category_name,
COUNT(*) as count
FROM work_issue_reports wir
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
WHERE ${whereClause}
GROUP BY irc.category_id
ORDER BY irc.category_type, count DESC`,
params
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 작업장별 통계
*/
const getStatsByWorkplace = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = 'wir.workplace_id IS NOT NULL';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.factory_category_id) {
whereClause += ` AND wir.factory_category_id = ?`;
params.push(filters.factory_category_id);
}
const [rows] = await db.query(
`SELECT
wir.factory_category_id, wc.category_name as factory_name,
wir.workplace_id, w.workplace_name,
COUNT(*) as count
FROM work_issue_reports wir
INNER JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
INNER JOIN workplaces w ON wir.workplace_id = w.workplace_id
WHERE ${whereClause}
GROUP BY wir.factory_category_id, wir.workplace_id
ORDER BY count DESC`,
params
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
module.exports = {
// 카테고리
getAllCategories,
getCategoriesByType,
createCategory,
updateCategory,
deleteCategory,
// 항목
getItemsByCategory,
getAllItems,
createItem,
updateItem,
deleteItem,
// 신고
createReport,
getAllReports,
getReportById,
updateReport,
deleteReport,
// 상태 관리
receiveReport,
assignReport,
startProcessing,
completeReport,
closeReport,
getStatusLogs,
// 통계
getStatsSummary,
getStatsByCategory,
getStatsByWorkplace
};

View File

@@ -0,0 +1,21 @@
{
"name": "system2-report-api",
"version": "1.0.0",
"description": "TK Factory Services - System 2 신고 API",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"express-rate-limit": "^7.5.1",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.14.1",
"redis": "^5.9.0"
}
}

View File

@@ -0,0 +1,92 @@
/**
* 작업 중 문제 신고 라우터
*/
const express = require('express');
const router = express.Router();
const workIssueController = require('../controllers/workIssueController');
const { requireMinLevel } = require('../middlewares/auth');
// ==================== 카테고리 관리 ====================
// 모든 카테고리 조회
router.get('/categories', workIssueController.getAllCategories);
// 타입별 카테고리 조회 (nonconformity/safety)
router.get('/categories/type/:type', workIssueController.getCategoriesByType);
// 카테고리 생성 (admin 이상)
router.post('/categories', requireMinLevel('admin'), workIssueController.createCategory);
// 카테고리 수정 (admin 이상)
router.put('/categories/:id', requireMinLevel('admin'), workIssueController.updateCategory);
// 카테고리 삭제 (admin 이상)
router.delete('/categories/:id', requireMinLevel('admin'), workIssueController.deleteCategory);
// ==================== 사전 정의 항목 관리 ====================
// 모든 항목 조회
router.get('/items', workIssueController.getAllItems);
// 카테고리별 항목 조회
router.get('/items/category/:categoryId', workIssueController.getItemsByCategory);
// 항목 생성 (admin 이상)
router.post('/items', requireMinLevel('admin'), workIssueController.createItem);
// 항목 수정 (admin 이상)
router.put('/items/:id', requireMinLevel('admin'), workIssueController.updateItem);
// 항목 삭제 (admin 이상)
router.delete('/items/:id', requireMinLevel('admin'), workIssueController.deleteItem);
// ==================== 통계 ====================
// 통계 요약 (support_team 이상)
router.get('/stats/summary', requireMinLevel('support_team'), workIssueController.getStatsSummary);
// 카테고리별 통계 (support_team 이상)
router.get('/stats/by-category', requireMinLevel('support_team'), workIssueController.getStatsByCategory);
// 작업장별 통계 (support_team 이상)
router.get('/stats/by-workplace', requireMinLevel('support_team'), workIssueController.getStatsByWorkplace);
// ==================== 문제 신고 관리 ====================
// 신고 목록 조회
router.get('/', workIssueController.getAllReports);
// 신고 생성
router.post('/', workIssueController.createReport);
// 신고 상세 조회
router.get('/:id', workIssueController.getReportById);
// 신고 수정
router.put('/:id', workIssueController.updateReport);
// 신고 삭제
router.delete('/:id', workIssueController.deleteReport);
// ==================== 상태 관리 ====================
// 신고 접수 (support_team 이상)
router.put('/:id/receive', requireMinLevel('support_team'), workIssueController.receiveReport);
// 담당자 배정 (support_team 이상)
router.put('/:id/assign', requireMinLevel('support_team'), workIssueController.assignReport);
// 처리 시작
router.put('/:id/start', workIssueController.startProcessing);
// 처리 완료
router.put('/:id/complete', workIssueController.completeReport);
// 신고 종료 (admin 이상)
router.put('/:id/close', requireMinLevel('admin'), workIssueController.closeReport);
// 상태 변경 이력 조회
router.get('/:id/logs', workIssueController.getStatusLogs);
module.exports = router;

View File

@@ -0,0 +1,217 @@
/**
* 이미지 업로드 서비스
* Base64 인코딩된 이미지를 파일로 저장
*
* 사용 전 sharp 패키지 설치 필요:
* npm install sharp
*/
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
// sharp는 선택적으로 사용 (설치되어 있지 않으면 리사이징 없이 저장)
let sharp;
try {
sharp = require('sharp');
} catch (e) {
console.warn('sharp 패키지가 설치되어 있지 않습니다. 이미지 리사이징이 비활성화됩니다.');
console.warn('이미지 최적화를 위해 npm install sharp 를 실행하세요.');
}
// 업로드 디렉토리 설정
const UPLOAD_DIRS = {
issues: path.join(__dirname, '../public/uploads/issues'),
equipments: path.join(__dirname, '../public/uploads/equipments')
};
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
const MAX_SIZE = { width: 1920, height: 1920 };
const QUALITY = 85;
/**
* 업로드 디렉토리 확인 및 생성
* @param {string} category - 카테고리 ('issues' 또는 'equipments')
*/
async function ensureUploadDir(category = 'issues') {
const uploadDir = UPLOAD_DIRS[category] || UPLOAD_DIRS.issues;
try {
await fs.access(uploadDir);
} catch {
await fs.mkdir(uploadDir, { recursive: true });
}
return uploadDir;
}
/**
* UUID 생성 (간단한 버전)
*/
function generateId() {
return crypto.randomBytes(4).toString('hex');
}
/**
* 타임스탬프 문자열 생성
*/
function getTimestamp() {
const now = new Date();
return now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
}
/**
* Base64 문자열에서 이미지 형식 추출
* @param {string} base64String - Base64 인코딩된 이미지
* @returns {string} 이미지 확장자 (jpg, png, etc)
*/
function getImageExtension(base64String) {
const match = base64String.match(/^data:image\/(\w+);base64,/);
if (match) {
const format = match[1].toLowerCase();
// jpeg를 jpg로 변환
return format === 'jpeg' ? 'jpg' : format;
}
return 'jpg'; // 기본값
}
/**
* Base64 이미지를 파일로 저장
* @param {string} base64String - Base64 인코딩된 이미지 (data:image/...;base64,... 형식)
* @param {string} prefix - 파일명 접두사 (예: 'issue', 'resolution', 'equipment')
* @param {string} category - 저장 카테고리 ('issues' 또는 'equipments')
* @returns {Promise<string|null>} 저장된 파일의 웹 경로 또는 null
*/
async function saveBase64Image(base64String, prefix = 'issue', category = 'issues') {
try {
if (!base64String || typeof base64String !== 'string') {
return null;
}
// Base64 헤더가 없는 경우 처리
let base64Data = base64String;
if (base64String.includes('base64,')) {
base64Data = base64String.split('base64,')[1];
}
// Base64 디코딩
const buffer = Buffer.from(base64Data, 'base64');
if (buffer.length === 0) {
console.error('이미지 데이터가 비어있습니다.');
return null;
}
// 디렉토리 확인
const uploadDir = await ensureUploadDir(category);
// 파일명 생성
const timestamp = getTimestamp();
const uniqueId = generateId();
const extension = 'jpg'; // 모든 이미지를 JPEG로 저장
const filename = `${prefix}_${timestamp}_${uniqueId}.${extension}`;
const filepath = path.join(uploadDir, filename);
// sharp가 설치되어 있으면 리사이징 및 최적화
if (sharp) {
try {
await sharp(buffer)
.resize(MAX_SIZE.width, MAX_SIZE.height, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: QUALITY })
.toFile(filepath);
} catch (sharpError) {
console.error('sharp 처리 실패, 원본 저장:', sharpError.message);
// sharp 실패 시 원본 저장
await fs.writeFile(filepath, buffer);
}
} else {
// sharp가 없으면 원본 그대로 저장
await fs.writeFile(filepath, buffer);
}
// 웹 접근 경로 반환
return `/uploads/${category}/${filename}`;
} catch (error) {
console.error('이미지 저장 실패:', error);
return null;
}
}
/**
* 여러 Base64 이미지를 한번에 저장
* @param {string[]} base64Images - Base64 이미지 배열
* @param {string} prefix - 파일명 접두사
* @returns {Promise<string[]>} 저장된 파일 경로 배열
*/
async function saveMultipleImages(base64Images, prefix = 'issue') {
const paths = [];
for (const base64 of base64Images) {
if (base64) {
const savedPath = await saveBase64Image(base64, prefix);
if (savedPath) {
paths.push(savedPath);
}
}
}
return paths;
}
/**
* 파일 삭제
* @param {string} webPath - 웹 경로 (예: /uploads/issues/filename.jpg)
* @returns {Promise<boolean>} 삭제 성공 여부
*/
async function deleteFile(webPath) {
try {
if (!webPath || typeof webPath !== 'string') {
return false;
}
// 보안: uploads 경로만 삭제 허용
if (!webPath.startsWith('/uploads/')) {
console.error('삭제 불가: uploads 외부 경로', webPath);
return false;
}
const filename = path.basename(webPath);
const fullPath = path.join(UPLOAD_DIR, filename);
try {
await fs.access(fullPath);
await fs.unlink(fullPath);
return true;
} catch (accessError) {
// 파일이 없으면 성공으로 처리
if (accessError.code === 'ENOENT') {
return true;
}
throw accessError;
}
} catch (error) {
console.error('파일 삭제 실패:', error);
return false;
}
}
/**
* 여러 파일 삭제
* @param {string[]} webPaths - 웹 경로 배열
* @returns {Promise<void>}
*/
async function deleteMultipleFiles(webPaths) {
for (const webPath of webPaths) {
if (webPath) {
await deleteFile(webPath);
}
}
}
module.exports = {
saveBase64Image,
saveMultipleImages,
deleteFile,
deleteMultipleFiles,
UPLOAD_DIR
};

View File

@@ -0,0 +1,383 @@
/**
* M-Project API 연동 서비스
*
* TK-FB-Project의 부적합 신고를 M-Project로 전송하는 서비스
*
* @author TK-FB-Project
* @since 2026-02-03
*/
const logger = require('../utils/logger');
// M-Project API 설정
const M_PROJECT_CONFIG = {
baseUrl: process.env.M_PROJECT_API_URL || 'http://system3-api:8000',
username: process.env.M_PROJECT_USERNAME || 'api_service',
password: process.env.M_PROJECT_PASSWORD || '',
defaultProjectId: parseInt(process.env.M_PROJECT_DEFAULT_PROJECT_ID) || 1,
};
// 캐시된 인증 토큰
let cachedToken = null;
let tokenExpiry = null;
/**
* TK-FB 카테고리를 M-Project 카테고리로 매핑
* @param {string} tkCategory - TK-FB-Project 카테고리
* @returns {string} M-Project 카테고리
*/
function mapCategoryToMProject(tkCategory) {
const categoryMap = {
// TK-FB 카테고리 → M-Project 카테고리
'자재 누락': 'material_missing',
'자재누락': 'material_missing',
'자재 관련': 'material_missing',
'설계 오류': 'design_error',
'설계미스': 'design_error',
'설계 관련': 'design_error',
'입고 불량': 'incoming_defect',
'입고자재 불량': 'incoming_defect',
'입고 관련': 'incoming_defect',
'검사 미스': 'inspection_miss',
'검사미스': 'inspection_miss',
'검사 관련': 'inspection_miss',
'기타': 'etc',
};
// 정확히 매칭되는 경우
if (categoryMap[tkCategory]) {
return categoryMap[tkCategory];
}
// 부분 매칭 시도
const lowerCategory = tkCategory?.toLowerCase() || '';
if (lowerCategory.includes('자재') || lowerCategory.includes('material')) {
return 'material_missing';
}
if (lowerCategory.includes('설계') || lowerCategory.includes('design')) {
return 'design_error';
}
if (lowerCategory.includes('입고') || lowerCategory.includes('incoming')) {
return 'incoming_defect';
}
if (lowerCategory.includes('검사') || lowerCategory.includes('inspection')) {
return 'inspection_miss';
}
// 기본값
return 'etc';
}
/**
* M-Project API 인증 토큰 획득
* @returns {Promise<string|null>} JWT 토큰
*/
async function getAuthToken() {
// 캐시된 토큰이 유효하면 재사용
if (cachedToken && tokenExpiry && new Date() < tokenExpiry) {
return cachedToken;
}
try {
const formData = new URLSearchParams();
formData.append('username', M_PROJECT_CONFIG.username);
formData.append('password', M_PROJECT_CONFIG.password);
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('M-Project 인증 실패', {
status: response.status,
error: errorText
});
return null;
}
const data = await response.json();
cachedToken = data.access_token;
// 토큰 만료 시간 설정 (기본 6일, M-Project는 7일 유효)
tokenExpiry = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000);
logger.info('M-Project 인증 성공');
return cachedToken;
} catch (error) {
logger.error('M-Project 인증 요청 오류', { error: error.message });
return null;
}
}
/**
* 이미지 URL을 Base64로 변환
* @param {string} imageUrl - 이미지 URL 또는 경로
* @returns {Promise<string|null>} Base64 인코딩된 이미지
*/
async function convertImageToBase64(imageUrl) {
if (!imageUrl) return null;
try {
// 이미 Base64인 경우 그대로 반환
if (imageUrl.startsWith('data:image')) {
return imageUrl;
}
// URL인 경우 fetch
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
const response = await fetch(imageUrl);
if (!response.ok) return null;
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
const contentType = response.headers.get('content-type') || 'image/jpeg';
return `data:${contentType};base64,${base64}`;
}
// 로컬 파일 경로인 경우
const fs = require('fs').promises;
const path = require('path');
const filePath = path.join(process.cwd(), 'uploads', imageUrl);
try {
const fileBuffer = await fs.readFile(filePath);
const base64 = fileBuffer.toString('base64');
const ext = path.extname(imageUrl).toLowerCase();
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
const contentType = mimeTypes[ext] || 'image/jpeg';
return `data:${contentType};base64,${base64}`;
} catch {
return null;
}
} catch (error) {
logger.warn('이미지 Base64 변환 실패', { imageUrl, error: error.message });
return null;
}
}
/**
* TK-FB-Project 부적합 신고를 M-Project로 전송
*
* @param {Object} issueData - 부적합 신고 데이터
* @param {string} issueData.category - 카테고리 이름
* @param {string} issueData.description - 신고 내용
* @param {string} issueData.reporter_name - 신고자 이름
* @param {string} issueData.project_name - 프로젝트 이름 (옵션)
* @param {number} issueData.tk_issue_id - TK-FB-Project 이슈 ID
* @param {string[]} issueData.photos - 사진 URL 배열 (최대 5개)
* @returns {Promise<{success: boolean, mProjectId?: number, error?: string}>}
*/
async function sendToMProject(issueData) {
const {
category,
description,
reporter_name,
project_name,
tk_issue_id,
photos = [],
} = issueData;
logger.info('M-Project 연동 시작', { tk_issue_id, category });
// 인증 토큰 획득
const token = await getAuthToken();
if (!token) {
return { success: false, error: 'M-Project 인증 실패' };
}
try {
// 카테고리 매핑
const mProjectCategory = mapCategoryToMProject(category);
// 설명에 TK-FB 정보 추가
const enhancedDescription = [
description,
'',
'---',
`[TK-FB-Project 연동]`,
`- 원본 이슈 ID: ${tk_issue_id}`,
`- 신고자: ${reporter_name || '미상'}`,
project_name ? `- 프로젝트: ${project_name}` : null,
].filter(Boolean).join('\n');
// 사진 변환 (최대 5개)
const photoPromises = photos.slice(0, 5).map(convertImageToBase64);
const base64Photos = await Promise.all(photoPromises);
// 요청 본문 구성
const requestBody = {
category: mProjectCategory,
description: enhancedDescription,
project_id: M_PROJECT_CONFIG.defaultProjectId,
};
// 사진 추가
base64Photos.forEach((photo, index) => {
if (photo) {
const fieldName = index === 0 ? 'photo' : `photo${index + 1}`;
requestBody[fieldName] = photo;
}
});
// M-Project API 호출
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/issues`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('M-Project 이슈 생성 실패', {
status: response.status,
error: errorText,
tk_issue_id
});
return {
success: false,
error: `M-Project API 오류: ${response.status}`
};
}
const createdIssue = await response.json();
logger.info('M-Project 이슈 생성 성공', {
tk_issue_id,
m_project_id: createdIssue.id
});
return {
success: true,
mProjectId: createdIssue.id
};
} catch (error) {
logger.error('M-Project 연동 오류', {
tk_issue_id,
error: error.message
});
return {
success: false,
error: error.message
};
}
}
/**
* M-Project에서 이슈 상태 조회
*
* @param {number} mProjectId - M-Project 이슈 ID
* @returns {Promise<{success: boolean, status?: string, data?: Object, error?: string}>}
*/
async function getIssueStatus(mProjectId) {
const token = await getAuthToken();
if (!token) {
return { success: false, error: 'M-Project 인증 실패' };
}
try {
const response = await fetch(
`${M_PROJECT_CONFIG.baseUrl}/api/issues/${mProjectId}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
}
);
if (!response.ok) {
return {
success: false,
error: `M-Project API 오류: ${response.status}`
};
}
const data = await response.json();
return {
success: true,
status: data.review_status || data.status,
data
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* M-Project 상태를 TK-FB 상태로 매핑
* @param {string} mProjectStatus - M-Project 상태
* @returns {string} TK-FB-Project 상태
*/
function mapStatusFromMProject(mProjectStatus) {
const statusMap = {
'pending_review': 'received',
'in_progress': 'in_progress',
'completed': 'completed',
'disposed': 'closed',
};
return statusMap[mProjectStatus] || 'received';
}
/**
* M-Project 연결 테스트
* @returns {Promise<{success: boolean, message: string}>}
*/
async function testConnection() {
try {
const token = await getAuthToken();
if (!token) {
return { success: false, message: 'M-Project 인증 실패' };
}
// /api/auth/me로 연결 테스트
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const user = await response.json();
return {
success: true,
message: `M-Project 연결 성공 (사용자: ${user.username})`
};
} else {
return {
success: false,
message: `M-Project 연결 실패: ${response.status}`
};
}
} catch (error) {
return {
success: false,
message: `M-Project 연결 오류: ${error.message}`
};
}
}
module.exports = {
sendToMProject,
getIssueStatus,
mapStatusFromMProject,
testConnection,
mapCategoryToMProject,
};

View File

@@ -0,0 +1,131 @@
/**
* 날짜/시간 유틸리티 함수
*
* 중요: 이 프로젝트는 한국(Asia/Seoul, UTC+9) 시간대 기준으로 운영됩니다.
* DB에 저장되는 비즈니스 날짜(report_date, session_date 등)는 반드시 한국 시간 기준이어야 합니다.
*
* @author TK-FB-Project
* @since 2026-02-03
*/
const KOREA_TIMEZONE_OFFSET = 9; // UTC+9
/**
* 한국 시간(KST) 기준 현재 Date 객체 반환
* @returns {Date} 한국 시간 기준 Date 객체
*/
function getKoreaDate() {
const now = new Date();
return new Date(now.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
}
/**
* 한국 시간(KST) 기준 현재 날짜 문자열 반환
* @returns {string} 'YYYY-MM-DD' 형식
*/
function getKoreaDateString() {
const koreaDate = getKoreaDate();
const year = koreaDate.getUTCFullYear();
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 한국 시간(KST) 기준 현재 datetime 문자열 반환
* @returns {string} 'YYYY-MM-DD HH:mm:ss' 형식 (MySQL DATETIME 호환)
*/
function getKoreaDatetime() {
const koreaDate = getKoreaDate();
const year = koreaDate.getUTCFullYear();
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* 한국 시간(KST) 기준 현재 시간 문자열 반환
* @returns {string} 'HH:mm:ss' 형식
*/
function getKoreaTimeString() {
const koreaDate = getKoreaDate();
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
/**
* UTC Date를 한국 시간 datetime 문자열로 변환
* @param {Date} date - UTC 기준 Date 객체
* @returns {string} 'YYYY-MM-DD HH:mm:ss' 형식
*/
function toKoreaDatetime(date) {
if (!date) return null;
const koreaDate = new Date(date.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
const year = koreaDate.getUTCFullYear();
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* UTC Date를 한국 날짜 문자열로 변환
* @param {Date} date - UTC 기준 Date 객체
* @returns {string} 'YYYY-MM-DD' 형식
*/
function toKoreaDateString(date) {
if (!date) return null;
const koreaDate = new Date(date.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
const year = koreaDate.getUTCFullYear();
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 한국 시간 datetime 문자열을 Date 객체로 변환
* @param {string} koreaDatetimeStr - 'YYYY-MM-DD HH:mm:ss' 형식
* @returns {Date} UTC 기준 Date 객체
*/
function fromKoreaDatetime(koreaDatetimeStr) {
if (!koreaDatetimeStr) return null;
// 한국 시간 문자열을 UTC로 변환
const date = new Date(koreaDatetimeStr + '+09:00');
return date;
}
/**
* 연도 반환 (한국 시간 기준)
* @returns {number} 현재 연도
*/
function getKoreaYear() {
return getKoreaDate().getUTCFullYear();
}
/**
* 월 반환 (한국 시간 기준, 1-12)
* @returns {number} 현재 월 (1-12)
*/
function getKoreaMonth() {
return getKoreaDate().getUTCMonth() + 1;
}
module.exports = {
KOREA_TIMEZONE_OFFSET,
getKoreaDate,
getKoreaDateString,
getKoreaDatetime,
getKoreaTimeString,
toKoreaDatetime,
toKoreaDateString,
fromKoreaDatetime,
getKoreaYear,
getKoreaMonth,
};

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

View File

@@ -0,0 +1,197 @@
/**
* 로깅 유틸리티
*
* 애플리케이션 전체에서 사용하는 통합 로거
*
* @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;