멤버 관리 API 구현 및 프론트엔드 연동

Backend:
- 멤버 CRUD API 추가 (routes/admin/members.js)
- 이미지 업로드 서비스 추가 (services/image.js)
  - S3에 3가지 해상도로 저장 (original, medium_800, thumb_400)
- multipart 플러그인 등록

Frontend:
- useAdminAuth 커스텀 훅 추가 (토큰 검증 API 사용)
- AdminMembers, AdminMemberEdit useQuery로 변경
- 로그인 확인 로직 중복 제거
- 수정 완료 시 목록 페이지로 이동 및 토스트 표시

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-16 23:01:23 +09:00
parent 1cb430907f
commit d44537d870
11 changed files with 2725 additions and 106 deletions

2250
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,13 +7,16 @@
"dev": "node --watch src/server.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.970.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0",
"bcrypt": "^6.0.0",
"dayjs": "^1.11.13",
"fastify": "^5.2.1",
"fastify-plugin": "^5.0.1",
"ioredis": "^5.4.2",
"mysql2": "^3.12.0",
"node-cron": "^3.0.3"
"node-cron": "^3.0.3",
"sharp": "^0.34.5"
}
}

View file

@ -1,4 +1,5 @@
import Fastify from 'fastify';
import multipart from '@fastify/multipart';
import config from './config/index.js';
// 플러그인
@ -23,6 +24,13 @@ export async function buildApp(opts = {}) {
// config 데코레이터 등록
fastify.decorate('config', config);
// multipart 플러그인 등록 (파일 업로드용)
await fastify.register(multipart, {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
},
});
// 플러그인 등록 (순서 중요)
await fastify.register(dbPlugin);
await fastify.register(redisPlugin);

View file

@ -23,4 +23,11 @@ export default {
secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026',
expiresIn: '30d',
},
s3: {
endpoint: process.env.RUSTFS_ENDPOINT,
accessKey: process.env.RUSTFS_ACCESS_KEY,
secretKey: process.env.RUSTFS_SECRET_KEY,
bucket: process.env.RUSTFS_BUCKET || 'fromis-9',
publicUrl: process.env.RUSTFS_PUBLIC_URL,
},
};

View file

@ -16,7 +16,7 @@ async function authPlugin(fastify, opts) {
try {
await request.jwtVerify();
} catch (err) {
reply.status(401).send({ error: '인증이 필요합니다.' });
return reply.status(401).send({ error: '인증이 필요합니다.' });
}
});
}

View file

@ -1,4 +1,5 @@
import authRoutes from './auth.js';
import membersRoutes from './members.js';
/**
* 어드민 라우트 통합
@ -6,4 +7,7 @@ import authRoutes from './auth.js';
export default async function adminRoutes(fastify, opts) {
// 인증 라우트 (prefix 없음)
fastify.register(authRoutes);
// 멤버 관리 라우트
fastify.register(membersRoutes, { prefix: '/members' });
}

View file

@ -0,0 +1,197 @@
import { uploadMemberImage } from '../../services/image.js';
/**
* 어드민 멤버 관리 라우트
*/
export default async function adminMembersRoutes(fastify, opts) {
/**
* GET /api/admin/members
* 멤버 목록 조회
*/
fastify.get('/', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
try {
const [members] = await fastify.db.query(`
SELECT
m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former,
i.original_url as image_original,
i.medium_url as image_medium,
i.thumb_url as image_thumb
FROM members m
LEFT JOIN images i ON m.image_id = i.id
ORDER BY m.is_former ASC, m.id ASC
`);
// 별명 조회
const [nicknames] = await fastify.db.query(
'SELECT member_id, nickname FROM member_nicknames'
);
// 멤버별 별명 매핑
const nicknameMap = {};
for (const n of nicknames) {
if (!nicknameMap[n.member_id]) {
nicknameMap[n.member_id] = [];
}
nicknameMap[n.member_id].push(n.nickname);
}
// 멤버 데이터에 별명 추가
const result = members.map(m => ({
...m,
nicknames: nicknameMap[m.id] || [],
image_url: m.image_thumb || m.image_medium || m.image_original,
}));
return result;
} catch (err) {
fastify.log.error(err);
return reply.status(500).send({ error: '멤버 목록 조회 실패' });
}
});
/**
* GET /api/admin/members/:name
* 멤버 상세 조회
*/
fastify.get('/:name', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { name } = request.params;
try {
const [members] = await fastify.db.query(`
SELECT
m.id, m.name, m.name_en, m.birth_date, m.instagram, m.image_id, m.is_former,
i.original_url as image_original,
i.medium_url as image_medium,
i.thumb_url as image_thumb
FROM members m
LEFT JOIN images i ON m.image_id = i.id
WHERE m.name = ?
`, [decodeURIComponent(name)]);
if (members.length === 0) {
return reply.status(404).send({ error: '멤버를 찾을 수 없습니다' });
}
const member = members[0];
// 별명 조회
const [nicknames] = await fastify.db.query(
'SELECT nickname FROM member_nicknames WHERE member_id = ?',
[member.id]
);
return {
...member,
nicknames: nicknames.map(n => n.nickname),
image_url: member.image_original || member.image_medium || member.image_thumb,
};
} catch (err) {
fastify.log.error(err);
return reply.status(500).send({ error: '멤버 조회 실패' });
}
});
/**
* PUT /api/admin/members/:name
* 멤버 수정
*/
fastify.put('/:name', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { name } = request.params;
const decodedName = decodeURIComponent(name);
try {
// 기존 멤버 조회
const [existing] = await fastify.db.query(
'SELECT id, image_id FROM members WHERE name = ?',
[decodedName]
);
if (existing.length === 0) {
return reply.status(404).send({ error: '멤버를 찾을 수 없습니다' });
}
const memberId = existing[0].id;
let imageId = existing[0].image_id;
// multipart 데이터 파싱
const parts = request.parts();
const fields = {};
let imageBuffer = null;
for await (const part of parts) {
if (part.type === 'file' && part.fieldname === 'image') {
// 이미지 파일
const chunks = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
imageBuffer = Buffer.concat(chunks);
} else if (part.type === 'field') {
// 일반 필드
fields[part.fieldname] = part.value;
}
}
// 새 이미지가 있으면 업로드
if (imageBuffer && imageBuffer.length > 0) {
const newName = fields.name || decodedName;
const { originalUrl, mediumUrl, thumbUrl } = await uploadMemberImage(newName, imageBuffer);
// images 테이블에 저장
const [result] = await fastify.db.query(
'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)',
[originalUrl, mediumUrl, thumbUrl]
);
imageId = result.insertId;
}
// 멤버 정보 업데이트
await fastify.db.query(`
UPDATE members SET
name = ?,
name_en = ?,
birth_date = ?,
instagram = ?,
image_id = ?,
is_former = ?
WHERE id = ?
`, [
fields.name || decodedName,
fields.name_en || null,
fields.birth_date || null,
fields.instagram || null,
imageId,
fields.is_former === 'true' || fields.is_former === '1' ? 1 : 0,
memberId,
]);
// 별명 업데이트 (기존 삭제 후 새로 추가)
if (fields.nicknames) {
await fastify.db.query(
'DELETE FROM member_nicknames WHERE member_id = ?',
[memberId]
);
const nicknames = JSON.parse(fields.nicknames);
if (nicknames.length > 0) {
const values = nicknames.map(n => [memberId, n]);
await fastify.db.query(
'INSERT INTO member_nicknames (member_id, nickname) VALUES ?',
[values]
);
}
}
return { message: '멤버 정보가 수정되었습니다', id: memberId };
} catch (err) {
fastify.log.error(err);
return reply.status(500).send({ error: '멤버 수정 실패: ' + err.message });
}
});
}

View file

@ -0,0 +1,99 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import sharp from 'sharp';
import config from '../config/index.js';
// S3 클라이언트 생성
const s3Client = new S3Client({
endpoint: config.s3.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: config.s3.accessKey,
secretAccessKey: config.s3.secretKey,
},
forcePathStyle: true,
});
const BUCKET = config.s3.bucket;
const PUBLIC_URL = config.s3.publicUrl;
/**
* 이미지를 3가지 해상도로 변환
*/
async function processImage(buffer) {
const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([
sharp(buffer).webp({ lossless: true }).toBuffer(),
sharp(buffer)
.resize(800, null, { withoutEnlargement: true })
.webp({ quality: 85 })
.toBuffer(),
sharp(buffer)
.resize(400, null, { withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer(),
]);
return { originalBuffer, mediumBuffer, thumbBuffer };
}
/**
* S3에 이미지 업로드
*/
async function uploadToS3(key, buffer, contentType = 'image/webp') {
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: buffer,
ContentType: contentType,
}));
return `${PUBLIC_URL}/${BUCKET}/${key}`;
}
/**
* S3에서 이미지 삭제
*/
async function deleteFromS3(key) {
try {
await s3Client.send(new DeleteObjectCommand({
Bucket: BUCKET,
Key: key,
}));
} catch (err) {
console.error(`S3 삭제 오류 (${key}):`, err.message);
}
}
/**
* 멤버 프로필 이미지 업로드
* @param {string} name - 멤버 이름
* @param {Buffer} buffer - 이미지 버퍼
* @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>}
*/
export async function uploadMemberImage(name, buffer) {
const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer);
const basePath = `member/${name}`;
const filename = `${name}.webp`;
// 병렬 업로드
const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([
uploadToS3(`${basePath}/original/${filename}`, originalBuffer),
uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer),
uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer),
]);
return { originalUrl, mediumUrl, thumbUrl };
}
/**
* 멤버 프로필 이미지 삭제
* @param {string} name - 멤버 이름
*/
export async function deleteMemberImage(name) {
const basePath = `member/${name}`;
const filename = `${name}.webp`;
const sizes = ['original', 'medium_800', 'thumb_400'];
await Promise.all(
sizes.map(size => deleteFromS3(`${basePath}/${size}/${filename}`))
);
}

View file

@ -0,0 +1,39 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { fetchAdminApi } from '../api';
/**
* 어드민 인증 상태 관리
* - 토큰 유효성 검증
* - 미인증 로그인 페이지로 리다이렉트
*/
function useAdminAuth() {
const navigate = useNavigate();
const token = localStorage.getItem('adminToken');
const { data, isLoading, isError } = useQuery({
queryKey: ['admin', 'auth'],
queryFn: () => fetchAdminApi('/api/admin/verify'),
enabled: !!token,
retry: false,
staleTime: 1000 * 60 * 5, // 5분간 캐시
});
// 토큰 없거나 검증 실패 시 로그인 페이지로 이동
useEffect(() => {
if (!token || isError) {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminUser');
navigate('/admin');
}
}, [token, isError, navigate]);
return {
user: data?.user || null,
isLoading: !token ? false : isLoading,
isAuthenticated: !!data?.valid,
};
}
export default useAdminAuth;

View file

@ -1,39 +1,26 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import {
Save, Upload, X,
Home, ChevronRight, User, Instagram, Calendar, Tag
} from 'lucide-react';
import { Save, Upload, X, Home, ChevronRight, User, Instagram, Calendar, Tag } from 'lucide-react';
import Toast from '../../../components/Toast';
import CustomDatePicker from '../../../components/admin/CustomDatePicker';
import AdminLayout from '../../../components/admin/AdminLayout';
import useAdminAuth from '../../../hooks/useAdminAuth';
import useToast from '../../../hooks/useToast';
// (API )
const DUMMY_MEMBERS = {
'이새롬': { id: 1, name: '이새롬', name_en: 'SAEROM', birth_date: '1997-01-07', instagram: 'https://www.instagram.com/saeromssee', is_former: true, image_url: null, nicknames: ['새롬', '큰새롬'] },
'송하영': { id: 2, name: '송하영', name_en: 'HAYOUNG', birth_date: '1997-09-29', instagram: 'https://www.instagram.com/shy9_29/', is_former: false, image_url: null, nicknames: ['하영', '햄', '햐미'] },
'장규리': { id: 3, name: '장규리', name_en: 'GYURI', birth_date: '1997-12-27', instagram: 'https://www.instagram.com/gyurious_j', is_former: true, image_url: null, nicknames: ['규리', '뀨리'] },
'박지원': { id: 4, name: '박지원', name_en: 'JIWON', birth_date: '1998-03-20', instagram: 'https://www.instagram.com/xjiwonparkx/', is_former: false, image_url: null, nicknames: ['지원', '쭈니', '지워니'] },
'노지선': { id: 5, name: '노지선', name_en: 'JISUN', birth_date: '1998-11-23', instagram: 'https://www.instagram.com/rosieline_', is_former: true, image_url: null, nicknames: ['지선'] },
'이서연': { id: 6, name: '이서연', name_en: 'SEOYEON', birth_date: '2000-01-22', instagram: 'https://www.instagram.com/im_theyeon', is_former: true, image_url: null, nicknames: ['서연', '써연'] },
'이채영': { id: 7, name: '이채영', name_en: 'CHAEYOUNG', birth_date: '2000-05-14', instagram: 'https://www.instagram.com/chaengrang_/', is_former: false, image_url: null, nicknames: ['채영', '채령'] },
'이나경': { id: 8, name: '이나경', name_en: 'NAKYUNG', birth_date: '2000-06-01', instagram: 'https://www.instagram.com/blossomlng_0/', is_former: false, image_url: null, nicknames: ['나경', '낭이', '나낭'] },
'백지헌': { id: 9, name: '백지헌', name_en: 'JIHEON', birth_date: '2003-04-17', instagram: 'https://www.instagram.com/jiheonnibaek/', is_former: false, image_url: null, nicknames: ['지헌', '지허니'] },
};
import { fetchAdminApi, fetchAdminFormData } from '../../../api';
function AdminMemberEdit() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { name } = useParams();
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const { user, isAuthenticated } = useAdminAuth();
const { toast, setToast } = useToast();
const [saving, setSaving] = useState(false);
const [imagePreview, setImagePreview] = useState(null);
const [imageFile, setImageFile] = useState(null);
const [nicknameInput, setNicknameInput] = useState('');
const [formData, setFormData] = useState({
name: '',
name_en: '',
@ -43,33 +30,37 @@ function AdminMemberEdit() {
nicknames: []
});
//
const { data: memberData, isLoading: loading, isError } = useQuery({
queryKey: ['admin', 'member', name],
queryFn: () => fetchAdminApi(`/api/admin/members/${encodeURIComponent(name)}`),
enabled: isAuthenticated,
});
//
useEffect(() => {
//
const token = localStorage.getItem('adminToken');
const userData = localStorage.getItem('adminUser');
if (!token || !userData) {
navigate('/admin');
return;
}
setUser(JSON.parse(userData));
// TODO: API fetchMember()
const memberData = DUMMY_MEMBERS[decodeURIComponent(name)];
if (memberData) {
const birthDate = memberData.birth_date
? memberData.birth_date.split('T')[0]
: '';
setFormData({
name: memberData.name || '',
name_en: memberData.name_en || '',
birth_date: memberData.birth_date || '',
birth_date: birthDate,
instagram: memberData.instagram || '',
is_former: !!memberData.is_former,
nicknames: memberData.nicknames || []
});
setImagePreview(memberData.image_url);
}
setLoading(false);
}, [navigate, name]);
}, [memberData]);
//
useEffect(() => {
if (isError) {
setToast({ message: '멤버 정보를 불러오는데 실패했습니다.', type: 'error' });
}
}, [isError, setToast]);
const handleImageChange = (e) => {
const file = e.target.files[0];
@ -113,13 +104,33 @@ function AdminMemberEdit() {
e.preventDefault();
setSaving(true);
// TODO: API
console.log('저장할 데이터:', formData, imageFile);
try {
const form = new FormData();
form.append('name', formData.name);
form.append('name_en', formData.name_en);
form.append('birth_date', formData.birth_date);
form.append('instagram', formData.instagram);
form.append('is_former', formData.is_former ? '1' : '0');
form.append('nicknames', JSON.stringify(formData.nicknames));
setTimeout(() => {
setToast({ message: '멤버 정보가 수정되었습니다. (API 미구현)', type: 'success' });
if (imageFile) {
form.append('image', imageFile);
}
await fetchAdminFormData(`/api/admin/members/${encodeURIComponent(name)}`, form, 'PUT');
// ( )
queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
//
navigate('/admin/members', {
state: { toast: { message: '멤버 정보가 수정되었습니다.', type: 'success' } }
});
} catch (err) {
setToast({ message: err.message || '멤버 수정에 실패했습니다.', type: 'error' });
} finally {
setSaving(false);
}, 500);
}
};
return (

View file

@ -1,57 +1,17 @@
import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useEffect } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import {
Edit2,
Home, ChevronRight, Users, User
} from 'lucide-react';
import { Edit2, Home, ChevronRight, Users, User } from 'lucide-react';
import Toast from '../../../components/Toast';
import AdminLayout from '../../../components/admin/AdminLayout';
import useAdminAuth from '../../../hooks/useAdminAuth';
import useToast from '../../../hooks/useToast';
// (API )
const DUMMY_MEMBERS = [
{ id: 1, name: '이새롬', name_en: 'SAEROM', is_former: true, image_url: null },
{ id: 2, name: '송하영', name_en: 'HAYOUNG', is_former: false, image_url: null },
{ id: 3, name: '장규리', name_en: 'GYURI', is_former: true, image_url: null },
{ id: 4, name: '박지원', name_en: 'JIWON', is_former: false, image_url: null },
{ id: 5, name: '노지선', name_en: 'JISUN', is_former: true, image_url: null },
{ id: 6, name: '이서연', name_en: 'SEOYEON', is_former: true, image_url: null },
{ id: 7, name: '이채영', name_en: 'CHAEYOUNG', is_former: false, image_url: null },
{ id: 8, name: '이나경', name_en: 'NAKYUNG', is_former: false, image_url: null },
{ id: 9, name: '백지헌', name_en: 'JIHEON', is_former: false, image_url: null },
];
function AdminMembers() {
const navigate = useNavigate();
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(true);
const [user, setUser] = useState(null);
const { toast, setToast } = useToast();
useEffect(() => {
//
const token = localStorage.getItem('adminToken');
const userData = localStorage.getItem('adminUser');
if (!token || !userData) {
navigate('/admin');
return;
}
setUser(JSON.parse(userData));
// TODO: API fetchMembers()
setMembers(DUMMY_MEMBERS);
setLoading(false);
}, [navigate]);
// / (is_former: 0=, 1=)
const activeMembers = members.filter(m => !m.is_former);
const formerMembers = members.filter(m => m.is_former);
import { fetchAdminApi } from '../../../api';
//
const MemberCard = ({ member, index, isFormer = false }) => (
function MemberCard({ member, index, isFormer = false, onClick }) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
@ -62,9 +22,8 @@ function AdminMembers() {
delay: index * 0.06
}}
className={`relative rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-all group cursor-pointer ${isFormer ? 'opacity-60' : ''}`}
onClick={() => navigate(`/admin/members/${encodeURIComponent(member.name)}/edit`)}
onClick={onClick}
>
{/* 프로필 이미지 */}
<div className={`aspect-[3/4] bg-gray-100 relative overflow-hidden ${isFormer ? 'grayscale' : ''}`}>
{member.image_url ? (
<img
@ -77,14 +36,10 @@ function AdminMembers() {
<User size={48} className="text-gray-300" />
</div>
)}
{/* 이름 오버레이 - 하단 그라데이션 */}
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-4">
<h3 className="text-lg font-bold text-white drop-shadow-lg">{member.name}</h3>
</div>
{/* 수정 버튼 오버레이 */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
<div className="px-4 py-2 bg-white/90 backdrop-blur-sm text-gray-900 rounded-lg font-medium flex items-center gap-2 shadow-lg">
<Edit2 size={16} />
@ -94,6 +49,43 @@ function AdminMembers() {
</div>
</motion.div>
);
}
function AdminMembers() {
const navigate = useNavigate();
const location = useLocation();
const { user, isAuthenticated } = useAdminAuth();
const { toast, setToast } = useToast();
//
useEffect(() => {
if (location.state?.toast) {
setToast(location.state.toast);
window.history.replaceState({}, '');
}
}, [location.state, setToast]);
//
const { data: members = [], isLoading: loading, isError } = useQuery({
queryKey: ['admin', 'members'],
queryFn: () => fetchAdminApi('/api/admin/members'),
enabled: isAuthenticated,
});
//
useEffect(() => {
if (isError) {
setToast({ message: '멤버 목록을 불러오는데 실패했습니다.', type: 'error' });
}
}, [isError, setToast]);
// / (is_former: 0=, 1=)
const activeMembers = members.filter(m => !m.is_former);
const formerMembers = members.filter(m => m.is_former);
const handleMemberClick = (memberName) => {
navigate(`/admin/members/${encodeURIComponent(memberName)}/edit`);
};
return (
<AdminLayout user={user}>
@ -137,7 +129,12 @@ function AdminMembers() {
{/* 5열 그리드 */}
<div className="grid grid-cols-5 gap-5">
{activeMembers.map((member, index) => (
<MemberCard key={member.id} member={member} index={index} />
<MemberCard
key={member.id}
member={member}
index={index}
onClick={() => handleMemberClick(member.name)}
/>
))}
</div>
</div>
@ -153,10 +150,16 @@ function AdminMembers() {
</span>
</div>
{/* 5열 그리드 (탈퇴 멤버용 - 4명이면 4개만 표시) */}
{/* 5열 그리드 */}
<div className="grid grid-cols-5 gap-5">
{formerMembers.map((member, index) => (
<MemberCard key={member.id} member={member} index={index} isFormer />
<MemberCard
key={member.id}
member={member}
index={index}
isFormer
onClick={() => handleMemberClick(member.name)}
/>
))}
</div>
</div>