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:
19
system2-report/api/Dockerfile
Normal file
19
system2-report/api/Dockerfile
Normal 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"]
|
||||
79
system2-report/api/config/database.js
Normal file
79
system2-report/api/config/database.js
Normal 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 };
|
||||
674
system2-report/api/controllers/workIssueController.js
Normal file
674
system2-report/api/controllers/workIssueController.js
Normal 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 });
|
||||
});
|
||||
};
|
||||
11
system2-report/api/dbPool.js
Normal file
11
system2-report/api/dbPool.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 데이터베이스 풀 (호환성 레거시 파일)
|
||||
*
|
||||
* @deprecated 이 파일은 하위 호환성을 위해 유지됩니다.
|
||||
* 새로운 코드에서는 './config/database'를 직접 import하세요.
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
module.exports = require('./config/database');
|
||||
99
system2-report/api/index.js
Normal file
99
system2-report/api/index.js
Normal 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;
|
||||
357
system2-report/api/middlewares/auth.js
Normal file
357
system2-report/api/middlewares/auth.js
Normal 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
|
||||
};
|
||||
887
system2-report/api/models/workIssueModel.js
Normal file
887
system2-report/api/models/workIssueModel.js
Normal 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
|
||||
};
|
||||
21
system2-report/api/package.json
Normal file
21
system2-report/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
92
system2-report/api/routes/workIssueRoutes.js
Normal file
92
system2-report/api/routes/workIssueRoutes.js
Normal 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;
|
||||
217
system2-report/api/services/imageUploadService.js
Normal file
217
system2-report/api/services/imageUploadService.js
Normal 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
|
||||
};
|
||||
383
system2-report/api/services/mProjectService.js
Normal file
383
system2-report/api/services/mProjectService.js
Normal 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,
|
||||
};
|
||||
131
system2-report/api/utils/dateUtils.js
Normal file
131
system2-report/api/utils/dateUtils.js
Normal 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,
|
||||
};
|
||||
186
system2-report/api/utils/errors.js
Normal file
186
system2-report/api/utils/errors.js
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 커스텀 에러 클래스
|
||||
*
|
||||
* 애플리케이션 전체에서 사용하는 표준화된 에러 클래스들
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
/**
|
||||
* 기본 애플리케이션 에러 클래스
|
||||
* 모든 커스텀 에러의 부모 클래스
|
||||
*/
|
||||
class AppError extends Error {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {number} statusCode - HTTP 상태 코드
|
||||
* @param {string} code - 에러 코드 (예: 'VALIDATION_ERROR')
|
||||
* @param {object} details - 추가 세부 정보
|
||||
*/
|
||||
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
this.isOperational = true; // 운영 에러 (예상된 에러)
|
||||
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 형태로 에러 정보 반환
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
statusCode: this.statusCode,
|
||||
code: this.code,
|
||||
details: this.details
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 에러 (400 Bad Request)
|
||||
* 입력값 검증 실패 시 사용
|
||||
*/
|
||||
class ValidationError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {object} details - 검증 실패 세부 정보
|
||||
*/
|
||||
constructor(message = '입력값이 올바르지 않습니다', details = null) {
|
||||
super(message, 400, 'VALIDATION_ERROR', details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 에러 (401 Unauthorized)
|
||||
* 인증이 필요하거나 인증 실패 시 사용
|
||||
*/
|
||||
class AuthenticationError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
*/
|
||||
constructor(message = '인증이 필요합니다') {
|
||||
super(message, 401, 'AUTHENTICATION_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 에러 (403 Forbidden)
|
||||
* 권한이 부족할 때 사용
|
||||
*/
|
||||
class ForbiddenError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
*/
|
||||
constructor(message = '권한이 없습니다') {
|
||||
super(message, 403, 'FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 없음 에러 (404 Not Found)
|
||||
* 요청한 리소스를 찾을 수 없을 때 사용
|
||||
*/
|
||||
class NotFoundError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {string} resource - 찾을 수 없는 리소스명
|
||||
*/
|
||||
constructor(message = '리소스를 찾을 수 없습니다', resource = null) {
|
||||
super(message, 404, 'NOT_FOUND', resource ? { resource } : null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 충돌 에러 (409 Conflict)
|
||||
* 중복된 리소스 등 충돌 발생 시 사용
|
||||
*/
|
||||
class ConflictError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {object} details - 충돌 세부 정보
|
||||
*/
|
||||
constructor(message = '이미 존재하는 데이터입니다', details = null) {
|
||||
super(message, 409, 'CONFLICT', details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 에러 (500 Internal Server Error)
|
||||
* 예상하지 못한 서버 오류 시 사용
|
||||
*/
|
||||
class InternalServerError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
*/
|
||||
constructor(message = '서버 오류가 발생했습니다') {
|
||||
super(message, 500, 'INTERNAL_SERVER_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 에러 (500 Internal Server Error)
|
||||
* DB 관련 오류 시 사용
|
||||
*/
|
||||
class DatabaseError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {Error} originalError - 원본 DB 에러
|
||||
*/
|
||||
constructor(message = '데이터베이스 오류가 발생했습니다', originalError = null) {
|
||||
super(
|
||||
message,
|
||||
500,
|
||||
'DATABASE_ERROR',
|
||||
originalError ? { originalMessage: originalError.message } : null
|
||||
);
|
||||
this.originalError = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 에러 (502 Bad Gateway)
|
||||
* 외부 서비스 호출 실패 시 사용
|
||||
*/
|
||||
class ExternalApiError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {string} service - 외부 서비스명
|
||||
*/
|
||||
constructor(message = '외부 서비스 호출에 실패했습니다', service = null) {
|
||||
super(message, 502, 'EXTERNAL_API_ERROR', service ? { service } : null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임아웃 에러 (504 Gateway Timeout)
|
||||
* 요청 처리 시간 초과 시 사용
|
||||
*/
|
||||
class TimeoutError extends AppError {
|
||||
/**
|
||||
* @param {string} message - 에러 메시지
|
||||
* @param {number} timeout - 타임아웃 시간 (ms)
|
||||
*/
|
||||
constructor(message = '요청 처리 시간이 초과되었습니다', timeout = null) {
|
||||
super(message, 504, 'TIMEOUT_ERROR', timeout ? { timeout } : null);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AppError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
InternalServerError,
|
||||
DatabaseError,
|
||||
ExternalApiError,
|
||||
TimeoutError
|
||||
};
|
||||
197
system2-report/api/utils/logger.js
Normal file
197
system2-report/api/utils/logger.js
Normal 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;
|
||||
Reference in New Issue
Block a user