/** * 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} 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} 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, project_id = null, tk_issue_id, photos = [], ssoToken = null, } = issueData; logger.info('M-Project 연동 시작', { tk_issue_id, category }); // SSO 토큰이 있으면 원래 사용자로 전송, 없으면 api_service 토큰 let token; if (ssoToken) { token = ssoToken; } else { 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: 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, };