- tkuser: 권한 관리를 별도 탭으로 분리, 부서 클릭 시 소속 인원 목록 표시 - system1: 모바일 UI 개선, nginx 권한 보정, 신고 카테고리 타입 마이그레이션 - system2: 신고 상세/보고서 개선, 내 보고서 페이지 추가 - system3: 이슈 뷰/수신함/관리함 개선 - gateway: 포털 라우팅 수정 - user-management API: 부서별 권한 벌크 설정 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
392 lines
10 KiB
JavaScript
392 lines
10 KiB
JavaScript
/**
|
|
* 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,
|
|
project_id = null,
|
|
tk_issue_id,
|
|
location_info = null,
|
|
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);
|
|
|
|
// 설명에 신고자 정보 추가
|
|
const enhancedDescription = [
|
|
description,
|
|
`- 신고자: ${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,
|
|
};
|
|
|
|
if (location_info) {
|
|
requestBody.location_info = location_info;
|
|
}
|
|
|
|
// 사진 추가
|
|
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,
|
|
};
|