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} - 사진 관리
- {/* 타이틀 */}
+ {/* 타이틀 + 액션 버튼 */}
사진 업로드 및 관리 사진 업로드 및 관리
- 컨셉/티저 이름은 각 사진별로 입력합니다.
+ {pendingFiles.length > 0
+ ? '파일이 추가된 상태에서는 타입을 변경할 수 없습니다.'
+ : photoType === 'teaser'
+ ? '티저 이미지는 순서만 지정하면 됩니다.'
+ : '컨셉/티저 이름은 각 사진별로 입력합니다.'
+ }
+
+ 추가 업로드 시 기존 사진 다음 번호로 설정하세요.
-
{album?.title}
-
+
{album?.title}
+
{file.filename}
- {/* 단체/솔로/유닛 선택 */} -