feat: 앨범 사진/티저 업로드 기능 구현

- SSE 기반 실시간 업로드 진행률 표시
- 컨셉 포토/티저 이미지 분리 (photo/ vs teaser/ 폴더)
- album_photos, album_teasers 테이블에 분리 저장
- 3개 해상도별 URL 컬럼 분리 (original_url, medium_url, thumb_url)
- 파일 업로드 시 타입 선택 잠금
- 티저 모드: 순서만 변경 가능, 메타 정보 입력 불필요
- 이미지 처리 병렬화로 성능 개선
- RUSTFS_PUBLIC_URL 환경변수 추가
This commit is contained in:
caadiq 2026-01-02 00:10:47 +09:00
parent ee23e5ffa4
commit fd1807f38c
3 changed files with 575 additions and 150 deletions

1
.env
View file

@ -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

View file

@ -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;

View file

@ -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,10 +254,12 @@ function AdminAlbumPhotos() {
return;
}
//
if (photoType === 'concept') {
// ( )
const missingConcept = pendingFiles.some(f => !f.conceptName.trim());
if (missingConcept) {
setToast({ message: '모든 사진의 컨셉/티저 이름을 입력해주세요.', type: 'warning' });
setToast({ message: '모든 사진의 컨셉을 입력해주세요.', type: 'warning' });
return;
}
@ -261,42 +271,93 @@ function AdminAlbumPhotos() {
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,13 +522,14 @@ function AdminAlbumPhotos() {
<span className="text-gray-700">{album?.title} - 사진 관리</span>
</motion.div>
{/* 타이틀 */}
{/* 타이틀 + 액션 버튼 */}
<motion.div
className="flex items-center gap-4 mb-8"
className="flex items-center justify-between mb-8"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
>
<div className="flex items-center gap-4">
<img
src={album?.cover_url}
alt={album?.title}
@ -477,6 +539,37 @@ function AdminAlbumPhotos() {
<h1 className="text-2xl font-bold text-gray-900">{album?.title}</h1>
<p className="text-gray-500">사진 업로드 관리</p>
</div>
</div>
{pendingFiles.length > 0 && (
<div className="flex items-center gap-3">
<button
onClick={() => {
pendingFiles.forEach(f => URL.revokeObjectURL(f.preview));
setPendingFiles([]);
}}
className="px-4 py-2 text-gray-600 hover:text-gray-900 transition-colors"
>
취소
</button>
<button
onClick={handleUpload}
disabled={saving}
className="flex items-center gap-2 px-5 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
>
{saving ? (
<>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
업로드 ...
</>
) : (
<>
<Save size={18} />
{pendingFiles.length} 사진 업로드
</>
)}
</button>
</div>
)}
</motion.div>
{/* 업로드 설정 */}
@ -496,27 +589,54 @@ function AdminAlbumPhotos() {
<div className="flex gap-2 max-w-xs">
<button
onClick={() => setPhotoType('concept')}
disabled={pendingFiles.length > 0}
className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
photoType === 'concept'
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
} ${pendingFiles.length > 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
>
컨셉 포토
</button>
<button
onClick={() => setPhotoType('teaser')}
disabled={pendingFiles.length > 0}
className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
photoType === 'teaser'
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
} ${pendingFiles.length > 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
>
티저 이미지
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
컨셉/티저 이름은 사진별로 입력합니다.
{pendingFiles.length > 0
? '파일이 추가된 상태에서는 타입을 변경할 수 없습니다.'
: photoType === 'teaser'
? '티저 이미지는 순서만 지정하면 됩니다.'
: '컨셉/티저 이름은 각 사진별로 입력합니다.'
}
</p>
</div>
{/* 시작 번호 설정 */}
<div className="mt-4 pt-4 border-t border-gray-100">
<label className="block text-sm font-medium text-gray-700 mb-2">시작 번호</label>
<div className="flex items-center gap-3">
<input
type="number"
min="1"
value={startNumber}
onChange={(e) => 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"
/>
<span className="text-sm text-gray-500">
{String(startNumber).padStart(2, '0')}.webp ~ {String(startNumber + Math.max(0, pendingFiles.length - 1)).padStart(2, '0')}.webp
</span>
</div>
<p className="text-xs text-gray-400 mt-2">
추가 업로드 기존 사진 다음 번호로 설정하세요.
</p>
</div>
</motion.div>
@ -529,13 +649,38 @@ function AdminAlbumPhotos() {
className="mb-6 bg-white rounded-xl p-4 border border-gray-100 shadow-sm"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-600">업로드 ...</span>
<span className="text-sm font-medium text-primary">{uploadProgress}%</span>
<div className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
{uploadProgress < 100 ? (
<span className="text-sm text-gray-600">파일 업로드 ...</span>
) : processingProgress.current > 0 ? (
<span className="text-sm text-gray-600">
{processingStatus || `${processingProgress.current}/${processingProgress.total} 처리 중...`}
</span>
) : (
<span className="text-sm text-gray-600">서버 연결 ...</span>
)}
</div>
<span className="text-sm font-medium text-primary">
{uploadProgress < 100
? `${uploadProgress}%`
: processingProgress.total > 0
? `${Math.round((processingProgress.current / processingProgress.total) * 100)}%`
: '0%'
}
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${uploadProgress}%` }}
animate={{
width: uploadProgress < 100
? `${uploadProgress}%`
: processingProgress.total > 0
? `${(processingProgress.current / processingProgress.total) * 100}%`
: '0%'
}}
transition={{ duration: 0.3 }}
className="h-full bg-primary rounded-full"
/>
</div>
@ -615,8 +760,8 @@ function AdminAlbumPhotos() {
<input
type="text"
inputMode="numeric"
defaultValue={String(index + 1).padStart(2, '0')}
key={`order-${file.id}-${index}`}
defaultValue={String(startNumber + index).padStart(2, '0')}
key={`order-${file.id}-${index}-${startNumber}`}
onBlur={(e) => {
const val = e.target.value.trim();
if (val && !isNaN(val)) {
@ -634,12 +779,12 @@ function AdminAlbumPhotos() {
/>
</div>
{/* 썸네일 (정사각형) */}
{/* 썸네일 (작게 축소하여 스크롤 성능 개선) */}
<img
src={file.preview}
alt={file.filename}
draggable="false"
className="w-36 h-36 rounded-xl object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0 select-none"
className="w-24 h-24 rounded-lg object-cover cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0 select-none"
onClick={() => setPreviewPhoto(file)}
/>
@ -648,6 +793,9 @@ function AdminAlbumPhotos() {
{/* 파일명 */}
<p className="text-base font-medium text-gray-900 truncate">{file.filename}</p>
{/* 컨셉 포토일 때만 메타 정보 입력 표시 */}
{photoType === 'concept' && (
<>
{/* 단체/솔로/유닛 선택 */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 w-16">타입:</span>
@ -679,17 +827,17 @@ function AdminAlbumPhotos() {
{file.groupType === 'group' ? (
<span className="text-sm text-gray-400">단체 사진은 멤버 태깅이 필요 없습니다</span>
) : (
MEMBERS.map(member => (
members.map(member => (
<button
key={member}
onClick={() => toggleMember(file.id, member)}
key={member.id}
onClick={() => toggleMember(file.id, member.id)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
file.members.includes(member)
file.members.includes(member.id)
? 'bg-primary text-white'
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
{member}
{member.name}
</button>
))
)}
@ -709,6 +857,8 @@ function AdminAlbumPhotos() {
placeholder="컨셉명을 입력하세요"
/>
</div>
</>
)}
</div>
{/* 삭제 버튼 */}
@ -726,41 +876,7 @@ function AdminAlbumPhotos() {
)}
</motion.div>
{/* 하단 액션 버튼 */}
{pendingFiles.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mt-6 flex justify-end gap-4"
>
<button
onClick={() => {
pendingFiles.forEach(f => URL.revokeObjectURL(f.preview));
setPendingFiles([]);
}}
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
>
취소
</button>
<button
onClick={handleUpload}
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
>
{saving ? (
<>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
업로드 ...
</>
) : (
<>
<Save size={18} />
{pendingFiles.length} 사진 업로드
</>
)}
</button>
</motion.div>
)}
{/* 삭제 확인 다이얼로그 */}
<AnimatePresence>