Files
TK-FB-Project/api.hyungi.net/services/imageUploadService.js
Hyungi Ahn 74d3a78aa3 feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현
- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성
  - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동
  - common/ → attendance/: 근태/휴가 관련 페이지 이동
  - admin/ 정리: safety-* 파일들을 safety/로 이동

- 사이드바 네비게이션 메뉴 구현
  - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리
  - 접기/펼치기 기능 및 상태 저장
  - 관리자 전용 메뉴 자동 표시/숨김

- 날씨 API 연동 (기상청 단기예보)
  - TBM 및 navbar에 현재 날씨 표시
  - weatherService.js 추가

- 안전 체크리스트 확장
  - 기본/날씨별/작업별 체크 유형 추가
  - checklist-manage.html 페이지 추가

- 이슈 신고 시스템 구현
  - workIssueController, workIssueModel, workIssueRoutes 추가

- DB 마이그레이션 파일 추가 (실행 대기)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:27:22 +09:00

210 lines
5.5 KiB
JavaScript

/**
* 이미지 업로드 서비스
* 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_DIR = path.join(__dirname, '../public/uploads/issues');
const MAX_SIZE = { width: 1920, height: 1920 };
const QUALITY = 85;
/**
* 업로드 디렉토리 확인 및 생성
*/
async function ensureUploadDir() {
try {
await fs.access(UPLOAD_DIR);
} catch {
await fs.mkdir(UPLOAD_DIR, { recursive: true });
}
}
/**
* 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')
* @returns {Promise<string|null>} 저장된 파일의 웹 경로 또는 null
*/
async function saveBase64Image(base64String, prefix = 'issue') {
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;
}
// 디렉토리 확인
await ensureUploadDir();
// 파일명 생성
const timestamp = getTimestamp();
const uniqueId = generateId();
const extension = 'jpg'; // 모든 이미지를 JPEG로 저장
const filename = `${prefix}_${timestamp}_${uniqueId}.${extension}`;
const filepath = path.join(UPLOAD_DIR, 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/issues/${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
};