- 4단계 상태 플로우: pending → grouped → purchased → received - 한국어 스마트 검색: 초성 매칭(ㅁㅈㄱ→면장갑), 별칭 테이블, 인메모리 캐시 - 모바일 전용 신청 페이지: 바텀시트 UI, FAB, 카드 리스트, 스크롤 페이지네이션 - 인라인 품목 등록: 미등록 품목 검색→등록→신청 단일 트랜잭션 - 관리자 그룹화: 체크박스 다중 선택, 구매 그룹(batch) 생성/일괄 구매/입고 - 입고 처리: 사진+보관위치 등록, 부분 입고 허용, batch 자동 상태 전환 - 알림: notifyHelper에 target_user_ids 추가, 구매진행중/입고완료 시 신청자 ntfy+push Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
220 lines
6.1 KiB
JavaScript
220 lines
6.1 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 를 실행하세요.');
|
|
}
|
|
|
|
// 업로드 디렉토리 설정 (Docker 볼륨 마운트: /usr/src/app/uploads)
|
|
const UPLOAD_DIRS = {
|
|
issues: path.join(__dirname, '../uploads/issues'),
|
|
equipments: path.join(__dirname, '../uploads/equipments'),
|
|
purchase_requests: path.join(__dirname, '../uploads/purchase_requests'),
|
|
purchase_received: path.join(__dirname, '../uploads/purchase_received')
|
|
};
|
|
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
|
|
};
|