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

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

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

View File

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

View File

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