From fd1807f38c5432390e99cfc63c89f2baead9b602 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 2 Jan 2026 00:10:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=A8=EB=B2=94=20=EC=82=AC=EC=A7=84?= =?UTF-8?q?/=ED=8B=B0=EC=A0=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSE 기반 실시간 업로드 진행률 표시 - 컨셉 포토/티저 이미지 분리 (photo/ vs teaser/ 폴더) - album_photos, album_teasers 테이블에 분리 저장 - 3개 해상도별 URL 컬럼 분리 (original_url, medium_url, thumb_url) - 파일 업로드 시 타입 선택 잠금 - 티저 모드: 순서만 변경 가능, 메타 정보 입력 불필요 - 이미지 처리 병렬화로 성능 개선 - RUSTFS_PUBLIC_URL 환경변수 추가 --- .env | 1 + backend/routes/admin.js | 308 +++++++++++++ .../src/pages/pc/admin/AdminAlbumPhotos.jsx | 416 +++++++++++------- 3 files changed, 575 insertions(+), 150 deletions(-) diff --git a/.env b/.env index fcb376c..0ef6e6f 100644 --- a/.env +++ b/.env @@ -12,6 +12,7 @@ JWT_SECRET=fromis9-admin-jwt-secret-2026-xK9mP2vL7nQ4w # RustFS (S3 Compatible) RUSTFS_ENDPOINT=https://rustfs.caadiq.co.kr +RUSTFS_PUBLIC_URL=https://s3.caadiq.co.kr RUSTFS_ACCESS_KEY=iOpbGJIn4VumvxXlSC6D RUSTFS_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd RUSTFS_BUCKET=fromis-9 diff --git a/backend/routes/admin.js b/backend/routes/admin.js index da6ee2e..d55e33f 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -406,4 +406,312 @@ router.delete("/albums/:id", authenticateToken, async (req, res) => { } }); +// ============================================ +// 앨범 사진 관리 API +// ============================================ + +// 앨범 사진 목록 조회 +router.get("/albums/:albumId/photos", async (req, res) => { + try { + const { albumId } = req.params; + + // 앨범 존재 확인 + const [albums] = await pool.query( + "SELECT folder_name FROM albums WHERE id = ?", + [albumId] + ); + if (albums.length === 0) { + return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); + } + + const folderName = albums[0].folder_name; + + // 사진 조회 (멤버 정보 포함) + const [photos] = await pool.query( + ` + SELECT + p.id, p.photo_url, p.photo_type, p.concept_name, + p.sort_order, p.width, p.height, p.file_size, + GROUP_CONCAT(pm.member_id) as member_ids + FROM album_photos p + LEFT JOIN album_photo_members pm ON p.id = pm.photo_id + WHERE p.album_id = ? + GROUP BY p.id + ORDER BY p.sort_order ASC + `, + [albumId] + ); + + // URL 변환 및 멤버 배열 파싱 + const result = photos.map((photo) => ({ + ...photo, + photo_url: photo.photo_url, + thumb_url: photo.photo_url.replace("/original/", "/thumb_400/"), + medium_url: photo.photo_url.replace("/original/", "/medium_800/"), + members: photo.member_ids ? photo.member_ids.split(",").map(Number) : [], + })); + + res.json(result); + } catch (error) { + console.error("사진 조회 오류:", error); + res.status(500).json({ error: "사진 조회 중 오류가 발생했습니다." }); + } +}); + +// 사진 업로드 (SSE로 실시간 진행률 전송) +router.post( + "/albums/:albumId/photos", + authenticateToken, + upload.array("photos", 50), + async (req, res) => { + // SSE 헤더 설정 + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + + const sendProgress = (current, total, message) => { + res.write(`data: ${JSON.stringify({ current, total, message })}\n\n`); + }; + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + const { albumId } = req.params; + const metadata = JSON.parse(req.body.metadata || "[]"); + const startNumber = parseInt(req.body.startNumber) || null; + const photoType = req.body.photoType || "concept"; // 'concept' | 'teaser' + + // 앨범 정보 조회 + const [albums] = await connection.query( + "SELECT folder_name FROM albums WHERE id = ?", + [albumId] + ); + if (albums.length === 0) { + return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); + } + + const folderName = albums[0].folder_name; + + // 시작 번호 결정 (클라이언트 지정 또는 기존 사진 다음 번호) + let nextOrder; + if (startNumber && startNumber > 0) { + nextOrder = startNumber; + } else { + const [existingPhotos] = await connection.query( + "SELECT MAX(sort_order) as maxOrder FROM album_photos WHERE album_id = ?", + [albumId] + ); + nextOrder = (existingPhotos[0].maxOrder || 0) + 1; + } + + const uploadedPhotos = []; + const totalFiles = req.files.length; + + for (let i = 0; i < req.files.length; i++) { + const file = req.files[i]; + const meta = metadata[i] || {}; + const orderNum = String(nextOrder + i).padStart(2, "0"); + const filename = `${orderNum}.webp`; + + // 진행률 전송 + sendProgress(i + 1, totalFiles, `${filename} 처리 중...`); + + // Sharp로 이미지 처리 (병렬) + const [originalBuffer, medium800Buffer, thumb400Buffer] = + await Promise.all([ + sharp(file.buffer).webp({ lossless: true }).toBuffer(), + sharp(file.buffer) + .resize(800, null, { withoutEnlargement: true }) + .webp({ quality: 85 }) + .toBuffer(), + sharp(file.buffer) + .resize(400, null, { withoutEnlargement: true }) + .webp({ quality: 80 }) + .toBuffer(), + ]); + + const originalMeta = await sharp(originalBuffer).metadata(); + + // RustFS 업로드 (병렬) + // 컨셉 포토: photo/, 티저 이미지: teaser/ + const subFolder = photoType === "teaser" ? "teaser" : "photo"; + const basePath = `album/${folderName}/${subFolder}`; + + await Promise.all([ + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/original/${filename}`, + Body: originalBuffer, + ContentType: "image/webp", + }) + ), + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/medium_800/${filename}`, + Body: medium800Buffer, + ContentType: "image/webp", + }) + ), + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/thumb_400/${filename}`, + Body: thumb400Buffer, + ContentType: "image/webp", + }) + ), + ]); + + // 3개 해상도별 URL 생성 + const originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`; + const mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${filename}`; + const thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${filename}`; + + let photoId; + + // DB 저장 - 티저와 컨셉 포토 분기 + if (photoType === "teaser") { + // 티저 이미지 → album_teasers 테이블 + const [result] = await connection.query( + `INSERT INTO album_teasers + (album_id, original_url, medium_url, thumb_url, sort_order) + VALUES (?, ?, ?, ?, ?)`, + [albumId, originalUrl, mediumUrl, thumbUrl, nextOrder + i] + ); + photoId = result.insertId; + } else { + // 컨셉 포토 → album_photos 테이블 + const [result] = await connection.query( + `INSERT INTO album_photos + (album_id, original_url, medium_url, thumb_url, photo_type, concept_name, sort_order, width, height, file_size) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + albumId, + originalUrl, + mediumUrl, + thumbUrl, + meta.groupType || "group", + meta.conceptName || null, + nextOrder + i, + originalMeta.width, + originalMeta.height, + originalBuffer.length, + ] + ); + photoId = result.insertId; + + // 멤버 태깅 저장 (컨셉 포토만) + if (meta.members && meta.members.length > 0) { + for (const memberId of meta.members) { + await connection.query( + "INSERT INTO album_photo_members (photo_id, member_id) VALUES (?, ?)", + [photoId, memberId] + ); + } + } + } + + uploadedPhotos.push({ + id: photoId, + original_url: originalUrl, + medium_url: mediumUrl, + thumb_url: thumbUrl, + filename, + }); + } + + await connection.commit(); + + // 완료 이벤트 전송 + res.write( + `data: ${JSON.stringify({ + done: true, + message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`, + photos: uploadedPhotos, + })}\n\n` + ); + res.end(); + } catch (error) { + await connection.rollback(); + console.error("사진 업로드 오류:", error); + res.write( + `data: ${JSON.stringify({ + error: "사진 업로드 중 오류가 발생했습니다.", + })}\n\n` + ); + res.end(); + } finally { + connection.release(); + } + } +); + +// 사진 삭제 +router.delete( + "/albums/:albumId/photos/:photoId", + authenticateToken, + async (req, res) => { + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + const { albumId, photoId } = req.params; + + // 사진 정보 조회 + const [photos] = await connection.query( + "SELECT p.*, a.folder_name FROM album_photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND p.album_id = ?", + [photoId, albumId] + ); + + if (photos.length === 0) { + return res.status(404).json({ error: "사진을 찾을 수 없습니다." }); + } + + const photo = photos[0]; + const filename = photo.photo_url.split("/").pop(); + const basePath = `album/${photo.folder_name}/photo`; + + // RustFS에서 삭제 (3가지 크기 모두) + const sizes = ["original", "medium_800", "thumb_400"]; + for (const size of sizes) { + try { + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/${size}/${filename}`, + }) + ); + } catch (s3Error) { + console.error(`S3 삭제 오류 (${size}):`, s3Error); + } + } + + // 멤버 태깅 삭제 + await connection.query( + "DELETE FROM album_photo_members WHERE photo_id = ?", + [photoId] + ); + + // 사진 삭제 + await connection.query("DELETE FROM album_photos WHERE id = ?", [ + photoId, + ]); + + await connection.commit(); + + res.json({ message: "사진이 삭제되었습니다." }); + } catch (error) { + await connection.rollback(); + console.error("사진 삭제 오류:", error); + res.status(500).json({ error: "사진 삭제 중 오류가 발생했습니다." }); + } finally { + connection.release(); + } + } +); + export default router; diff --git a/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx b/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx index ebeb8bd..9454039 100644 --- a/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx +++ b/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx @@ -9,9 +9,6 @@ import { } from 'lucide-react'; import Toast from '../../../components/Toast'; -// 멤버 목록 -const MEMBERS = ['이서연', '송하영', '장규리', '박지원', '이나경', '이채영', '백지헌']; - function AdminAlbumPhotos() { const { albumId } = useParams(); const navigate = useNavigate(); @@ -29,12 +26,16 @@ function AdminAlbumPhotos() { const [deleting, setDeleting] = useState(false); const [previewPhoto, setPreviewPhoto] = useState(null); const [dragOver, setDragOver] = useState(false); + const [members, setMembers] = useState([]); // DB에서 로드 // 업로드 대기 중인 파일들 const [pendingFiles, setPendingFiles] = useState([]); const [photoType, setPhotoType] = useState('concept'); // 'concept' | 'teaser' const [conceptName, setConceptName] = useState(''); + const [startNumber, setStartNumber] = useState(1); // 시작 번호 const [saving, setSaving] = useState(false); + const [processingStatus, setProcessingStatus] = useState(''); // 처리 상태 메시지 + const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0 }); // 서버 처리 진행률 const [pendingDeleteId, setPendingDeleteId] = useState(null); // 삭제 대기 파일 ID // Toast 자동 숨김 @@ -67,6 +68,13 @@ function AdminAlbumPhotos() { const albumData = await albumRes.json(); setAlbum(albumData); + // 멤버 목록 로드 + const membersRes = await fetch('/api/members'); + if (membersRes.ok) { + const membersData = await membersRes.json(); + setMembers(membersData); + } + // TODO: 기존 사진 목록 로드 (API 구현 후) setPhotos([]); setLoading(false); @@ -246,57 +254,110 @@ function AdminAlbumPhotos() { return; } - // 컨셉명 검증 (각 파일별로) - const missingConcept = pendingFiles.some(f => !f.conceptName.trim()); - if (missingConcept) { - setToast({ message: '모든 사진의 컨셉/티저 이름을 입력해주세요.', type: 'warning' }); - return; - } - - // 솔로/유닛인데 멤버 선택 안 한 경우 - const missingMembers = pendingFiles.some(f => - (f.groupType === 'solo' || f.groupType === 'unit') && f.members.length === 0 - ); - if (missingMembers) { - setToast({ message: '솔로/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' }); - return; + // 컨셉 포토일 때만 검증 + if (photoType === 'concept') { + // 컨셉명 검증 (각 파일별로) + const missingConcept = pendingFiles.some(f => !f.conceptName.trim()); + if (missingConcept) { + setToast({ message: '모든 사진의 컨셉명을 입력해주세요.', type: 'warning' }); + return; + } + + // 솔로/유닛인데 멤버 선택 안 한 경우 + const missingMembers = pendingFiles.some(f => + (f.groupType === 'solo' || f.groupType === 'unit') && f.members.length === 0 + ); + if (missingMembers) { + setToast({ message: '솔로/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' }); + return; + } } setSaving(true); setUploadProgress(0); + setProcessingProgress({ current: 0, total: pendingFiles.length }); + setProcessingStatus(''); try { - // 임시 진행률 시뮬레이션 - for (let i = 0; i <= 100; i += 10) { - await new Promise(r => setTimeout(r, 100)); - setUploadProgress(i); + const token = localStorage.getItem('adminToken'); + + // FormData 생성 + const formData = new FormData(); + const metadata = pendingFiles.map(pf => ({ + groupType: pf.groupType, + members: pf.members, + conceptName: pf.conceptName, + })); + + pendingFiles.forEach(pf => { + formData.append('photos', pf.file); + }); + formData.append('metadata', JSON.stringify(metadata)); + formData.append('startNumber', startNumber); + formData.append('photoType', photoType); + + // 업로드 진행률 + SSE로 서버 처리 진행률 + const response = await fetch(`/api/admin/albums/${albumId}/photos`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: formData, + }); + + setUploadProgress(100); + + // SSE 응답 읽기 + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let result = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value); + const lines = text.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + if (data.done) { + result = data; + } else if (data.error) { + throw new Error(data.error); + } else if (data.current) { + setProcessingProgress({ current: data.current, total: data.total }); + setProcessingStatus(data.message); + } + } catch (e) { + // JSON 파싱 실패 무시 + } + } + } } - // TODO: 실제 업로드 API 호출 - // const formData = new FormData(); - // pendingFiles.forEach((pf, idx) => { - // formData.append('photos', pf.file); - // formData.append(`meta_${idx}`, JSON.stringify({ - // order: idx + 1, - // groupType: pf.groupType, - // members: pf.members, - // })); - // }); - // formData.append('photoType', photoType); - // formData.append('conceptName', conceptName); - - setToast({ message: `${pendingFiles.length}개의 사진이 업로드되었습니다.`, type: 'success' }); + if (result) { + setToast({ message: result.message, type: 'success' }); + } // 미리보기 URL 해제 pendingFiles.forEach(f => URL.revokeObjectURL(f.preview)); setPendingFiles([]); setConceptName(''); + setStartNumber(1); // 초기화 + + // 사진 목록 다시 로드 + fetchAlbumData(); } catch (error) { console.error('업로드 오류:', error); - setToast({ message: '업로드 중 오류가 발생했습니다.', type: 'error' }); + setToast({ message: error.message || '업로드 중 오류가 발생했습니다.', type: 'error' }); } finally { setSaving(false); setUploadProgress(0); + setProcessingProgress({ current: 0, total: 0 }); + setProcessingStatus(''); } }; @@ -461,22 +522,54 @@ function AdminAlbumPhotos() { {album?.title} - 사진 관리 - {/* 타이틀 */} + {/* 타이틀 + 액션 버튼 */} - {album?.title} -
-

{album?.title}

-

사진 업로드 및 관리

+
+ {album?.title} +
+

{album?.title}

+

사진 업로드 및 관리

+
+ {pendingFiles.length > 0 && ( +
+ + +
+ )} {/* 업로드 설정 */} @@ -496,27 +589,54 @@ function AdminAlbumPhotos() {

- 컨셉/티저 이름은 각 사진별로 입력합니다. + {pendingFiles.length > 0 + ? '파일이 추가된 상태에서는 타입을 변경할 수 없습니다.' + : photoType === 'teaser' + ? '티저 이미지는 순서만 지정하면 됩니다.' + : '컨셉/티저 이름은 각 사진별로 입력합니다.' + } +

+
+ + {/* 시작 번호 설정 */} +
+ +
+ setStartNumber(Math.max(1, parseInt(e.target.value) || 1))} + className="w-20 px-3 py-2 border border-gray-200 rounded-lg text-center focus:outline-none focus:ring-2 focus:ring-primary [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + + → {String(startNumber).padStart(2, '0')}.webp ~ {String(startNumber + Math.max(0, pendingFiles.length - 1)).padStart(2, '0')}.webp + +
+

+ 추가 업로드 시 기존 사진 다음 번호로 설정하세요.

@@ -529,13 +649,38 @@ function AdminAlbumPhotos() { className="mb-6 bg-white rounded-xl p-4 border border-gray-100 shadow-sm" >
- 업로드 중... - {uploadProgress}% +
+ + {uploadProgress < 100 ? ( + 파일 업로드 중... + ) : processingProgress.current > 0 ? ( + + {processingStatus || `${processingProgress.current}/${processingProgress.total} 처리 중...`} + + ) : ( + 서버 연결 중... + )} +
+ + {uploadProgress < 100 + ? `${uploadProgress}%` + : processingProgress.total > 0 + ? `${Math.round((processingProgress.current / processingProgress.total) * 100)}%` + : '0%' + } +
0 + ? `${(processingProgress.current / processingProgress.total) * 100}%` + : '0%' + }} + transition={{ duration: 0.3 }} className="h-full bg-primary rounded-full" />
@@ -615,8 +760,8 @@ function AdminAlbumPhotos() { { const val = e.target.value.trim(); if (val && !isNaN(val)) { @@ -634,12 +779,12 @@ function AdminAlbumPhotos() { /> - {/* 썸네일 (정사각형) */} + {/* 썸네일 (작게 축소하여 스크롤 성능 개선) */} {file.filename} setPreviewPhoto(file)} /> @@ -648,67 +793,72 @@ function AdminAlbumPhotos() { {/* 파일명 */}

{file.filename}

- {/* 단체/솔로/유닛 선택 */} -
- 타입: -
- {[ - { value: 'group', icon: Users, label: '단체' }, - { value: 'solo', icon: User, label: '솔로' }, - { value: 'unit', icon: Users2, label: '유닛' }, - ].map(({ value, icon: Icon, label }) => ( - - ))} -
-
+ {/* 컨셉 포토일 때만 메타 정보 입력 표시 */} + {photoType === 'concept' && ( + <> + {/* 단체/솔로/유닛 선택 */} +
+ 타입: +
+ {[ + { value: 'group', icon: Users, label: '단체' }, + { value: 'solo', icon: User, label: '솔로' }, + { value: 'unit', icon: Users2, label: '유닛' }, + ].map(({ value, icon: Icon, label }) => ( + + ))} +
+
- {/* 멤버 태깅 (단체는 비활성화) */} -
- 멤버: - {file.groupType === 'group' ? ( - 단체 사진은 멤버 태깅이 필요 없습니다 - ) : ( - MEMBERS.map(member => ( - - )) - )} - {file.groupType === 'solo' && ( - (한 명만 선택) - )} -
+ {/* 멤버 태깅 (단체는 비활성화) */} +
+ 멤버: + {file.groupType === 'group' ? ( + 단체 사진은 멤버 태깅이 필요 없습니다 + ) : ( + members.map(member => ( + + )) + )} + {file.groupType === 'solo' && ( + (한 명만 선택) + )} +
- {/* 컨셉/티저 이름 (개별 입력) */} -
- 컨셉명: - updatePendingFile(file.id, 'conceptName', e.target.value)} - className="flex-1 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" - placeholder="컨셉명을 입력하세요" - /> -
+ {/* 컨셉/티저 이름 (개별 입력) */} +
+ 컨셉명: + updatePendingFile(file.id, 'conceptName', e.target.value)} + className="flex-1 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" + placeholder="컨셉명을 입력하세요" + /> +
+ + )} {/* 삭제 버튼 */} @@ -726,41 +876,7 @@ function AdminAlbumPhotos() { )} - {/* 하단 액션 버튼 */} - {pendingFiles.length > 0 && ( - - - - - )} + {/* 삭제 확인 다이얼로그 */}