feat: add ESP32 DIY platform Phase 1 (marketplace scaffold)

- Docker Compose with Postgres, Redis, MinIO, backend, frontend (port 3200/3201)
- Prisma schema: User, Project, ProjectFile, Product, Order, FlashToken, Review, AuditLog
- Backend: JWT auth, project CRUD + file upload (MinIO + sharp WebP), admin approval flow
- Frontend: React + Vite SPA with auth, project/shop browse, seller dashboard, admin panel
- Admin: pending approval queue, user management, audit log viewer, stats dashboard
- Audit logging middleware for legal compliance
- Admin init script: createAdmin.js
- Full design document in PLATFORM_DESIGN.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-05-20 06:05:46 +09:00
parent bc5dd5dba7
commit bdef4b7ae0
46 changed files with 4372 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import api from '../../api/client';
export default function AdminIndex() {
const [stats, setStats] = useState(null);
useEffect(() => {
api.get('/admin/stats').then(r => setStats(r.data)).catch(() => {});
}, []);
const cards = stats ? [
{ label: '총 사용자', value: stats.users.toLocaleString(), icon: '👤' },
{ label: '전체 프로젝트', value: stats.projects.total.toLocaleString(), icon: '📁' },
{ label: '승인 대기', value: stats.projects.pending.toLocaleString(), icon: '⏳', warn: stats.projects.pending > 0 },
{ label: '총 매출', value: `${(stats.revenue.total || 0).toLocaleString()}`, icon: '💰' },
{ label: '총 주문', value: stats.revenue.orders.toLocaleString(), icon: '🛒' },
] : [];
return (
<div className="container page">
<h2 style={{ marginBottom: 24 }}>관리자 대시보드</h2>
{/* 통계 카드 */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 16, marginBottom: 32 }}>
{cards.map(c => (
<div key={c.label} className="card" style={{ borderColor: c.warn ? 'var(--warn)' : undefined }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>{c.icon}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: c.warn ? 'var(--warn)' : 'var(--text)' }}>{c.value}</div>
<div className="text-muted" style={{ fontSize: 13 }}>{c.label}</div>
</div>
))}
</div>
)}
{/* 빠른 메뉴 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
{[
{ to: '/admin/projects', icon: '📋', title: '프로젝트 승인', desc: '검토 대기 중인 프로젝트를 승인/반려' },
{ to: '/admin/users', icon: '👥', title: '사용자 관리', desc: '사용자 목록, 역할 변경, 계정 비활성화' },
{ to: '/admin/logs', icon: '📜', title: '감사 로그', desc: '모든 주요 행동 기록 조회' },
].map(m => (
<Link key={m.to} to={m.to} style={{ textDecoration: 'none' }}>
<div className="card" style={{ cursor: 'pointer', transition: 'border-color .2s' }}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
<div style={{ fontSize: 32, marginBottom: 8 }}>{m.icon}</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{m.title}</div>
<div className="text-muted" style={{ fontSize: 13 }}>{m.desc}</div>
</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';
import api from '../../api/client';
const ACTION_COLORS = {
REGISTER: 'var(--success)', LOGIN: 'var(--success)', LOGIN_FAIL: 'var(--danger)',
LOGOUT: 'var(--text2)', PROJECT_CREATE: 'var(--accent2)', PROJECT_SUBMIT: 'var(--warn)',
PROJECT_DELETE: 'var(--danger)', ADMIN_APPROVE: 'var(--success)', ADMIN_REJECT: 'var(--danger)',
ADMIN_USER_DEACTIVATE: 'var(--danger)', ORDER_CREATE: 'var(--accent2)', PAYMENT_CONFIRM: 'var(--success)',
};
export default function AdminLogs() {
const [data, setData] = useState({ logs: [], total: 0, pages: 1 });
const [page, setPage] = useState(1);
const [filter, setFilter] = useState({ action: '', userId: '' });
const [loading, setLoading] = useState(true);
function load() {
setLoading(true);
const params = new URLSearchParams({ page, limit: 50 });
if (filter.action) params.set('action', filter.action);
if (filter.userId) params.set('userId', filter.userId);
api.get(`/admin/logs?${params}`)
.then(r => setData(r.data))
.catch(() => {})
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, [page]);
return (
<div className="container page">
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2>감사 로그</h2>
<span className="text-muted" style={{ fontSize: 13 }}> {data.total.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
<input placeholder="Action 필터 (예: LOGIN)" value={filter.action}
onChange={e => setFilter(f => ({ ...f, action: e.target.value }))} style={{ maxWidth: 200 }} />
<input placeholder="User ID" value={filter.userId}
onChange={e => setFilter(f => ({ ...f, userId: e.target.value }))} style={{ maxWidth: 200 }} />
<button className="btn btn-outline" onClick={() => { setPage(1); load(); }}>검색</button>
</div>
{loading ? <div className="spinner" /> : (
<>
<div className="table-wrap">
<table>
<thead>
<tr><th>시간</th><th>Action</th><th>사용자</th><th>대상</th><th>IP</th><th>상태</th></tr>
</thead>
<tbody>
{data.logs.map(log => (
<tr key={log.id}>
<td style={{ fontSize: 12, color: 'var(--text2)', whiteSpace: 'nowrap' }}>
{new Date(log.createdAt).toLocaleString('ko-KR')}
</td>
<td>
<code style={{ fontSize: 12, color: ACTION_COLORS[log.action] || 'var(--text)', background: 'var(--bg3)', padding: '2px 6px', borderRadius: 4 }}>
{log.action}
</code>
</td>
<td style={{ fontSize: 12 }}>
{log.user ? `${log.user.nickname} (${log.user.email})` : '-'}
</td>
<td style={{ fontSize: 12, color: 'var(--text2)' }}>
{log.targetType ? `${log.targetType} ${log.targetId?.slice(0, 8)}...` : '-'}
</td>
<td style={{ fontSize: 12, color: 'var(--text2)' }}>{log.ipAddress}</td>
<td>
{log.responseStatus && (
<span style={{ fontSize: 12, color: log.responseStatus < 400 ? 'var(--success)' : 'var(--danger)' }}>
{log.responseStatus}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.pages > 1 && (
<div className="pagination">
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
{Array.from({ length: Math.min(data.pages, 10) }, (_, i) => (
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>{i + 1}</button>
))}
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react';
import api from '../../api/client';
export default function AdminProjects() {
const [pending, setPending] = useState([]);
const [selected, setSelected] = useState(null);
const [approveForm, setApproveForm] = useState({ price: '', commissionRate: '0.1' });
const [rejectNote, setRejectNote] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
api.get('/admin/projects/pending')
.then(r => setPending(r.data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
async function handleApprove() {
if (!approveForm.price) { setMsg('판매가를 입력해주세요'); return; }
setSaving(true);
try {
await api.post(`/admin/projects/${selected.id}/approve`, {
price: parseInt(approveForm.price),
commissionRate: parseFloat(approveForm.commissionRate),
});
setPending(p => p.filter(x => x.id !== selected.id));
setSelected(null);
setMsg('승인되었습니다');
} catch (err) {
setMsg(err.response?.data?.error || '오류 발생');
} finally {
setSaving(false);
}
}
async function handleReject() {
if (!rejectNote.trim()) { setMsg('반려 사유를 입력해주세요'); return; }
setSaving(true);
try {
await api.post(`/admin/projects/${selected.id}/reject`, { adminNote: rejectNote });
setPending(p => p.filter(x => x.id !== selected.id));
setSelected(null);
setRejectNote('');
setMsg('반려 처리되었습니다');
} catch (err) {
setMsg(err.response?.data?.error || '오류 발생');
} finally {
setSaving(false);
}
}
if (loading) return <div className="spinner" />;
return (
<div className="container page">
<h2 style={{ marginBottom: 8 }}>프로젝트 승인 관리</h2>
<p className="text-muted" style={{ marginBottom: 24 }}>검토 대기 중인 프로젝트: {pending.length}</p>
{msg && <div className="alert alert-info" onClick={() => setMsg('')}>{msg}</div>}
{pending.length === 0 ? (
<div className="card text-center" style={{ padding: 48 }}>
<p className="text-muted">검토 대기 중인 프로젝트가 없습니다.</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 1fr' : '1fr', gap: 20 }}>
{/* 목록 */}
<div>
{pending.map(p => (
<div key={p.id} className="card" style={{ marginBottom: 12, cursor: 'pointer', borderColor: selected?.id === p.id ? 'var(--accent)' : 'var(--border)' }}
onClick={() => { setSelected(p); setApproveForm({ price: '', commissionRate: '0.1' }); setRejectNote(''); }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{p.files[0] && (
<img src={p.files[0].thumbnailUrl || p.files[0].url} alt=""
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
)}
<div>
<div style={{ fontWeight: 600 }}>{p.title}</div>
<div className="text-muted" style={{ fontSize: 12 }}>
by {p.user.nickname} · 파일 {p._count.files} ·{' '}
{new Date(p.updatedAt).toLocaleDateString('ko-KR')}
</div>
</div>
</div>
</div>
))}
</div>
{/* 상세 / 승인 패널 */}
{selected && (
<div className="card" style={{ height: 'fit-content', position: 'sticky', top: 80 }}>
<h3 style={{ marginBottom: 16 }}>{selected.title}</h3>
<p className="text-muted" style={{ fontSize: 13, marginBottom: 16, maxHeight: 120, overflow: 'auto' }}>
{selected.description}
</p>
<div className="divider" />
<h4 style={{ marginBottom: 12 }}>승인</h4>
<div className="form-group">
<label>판매가 ()</label>
<input type="number" min="100" placeholder="예: 9900"
value={approveForm.price} onChange={e => setApproveForm(f => ({ ...f, price: e.target.value }))} />
</div>
<div className="form-group">
<label>수수료율 (0.0 ~ 1.0)</label>
<input type="number" min="0" max="1" step="0.01"
value={approveForm.commissionRate}
onChange={e => setApproveForm(f => ({ ...f, commissionRate: e.target.value }))} />
</div>
<button className="btn btn-success" onClick={handleApprove} disabled={saving} style={{ width: '100%', marginBottom: 12, justifyContent: 'center' }}>
{saving ? '처리 중...' : '✓ 승인하기'}
</button>
<div className="divider" />
<h4 style={{ marginBottom: 12 }}>반려</h4>
<div className="form-group">
<label>반려 사유</label>
<textarea value={rejectNote} onChange={e => setRejectNote(e.target.value)} rows={3}
placeholder="수정이 필요한 내용을 구체적으로 작성해주세요" />
</div>
<button className="btn btn-danger" onClick={handleReject} disabled={saving} style={{ width: '100%', justifyContent: 'center' }}>
{saving ? '처리 중...' : '✕ 반려하기'}
</button>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react';
import api from '../../api/client';
export default function AdminUsers() {
const [data, setData] = useState({ users: [], total: 0, pages: 1 });
const [page, setPage] = useState(1);
const [q, setQ] = useState('');
const [loading, setLoading] = useState(true);
function load() {
setLoading(true);
const params = new URLSearchParams({ page, limit: 30 });
if (q) params.set('q', q);
api.get(`/admin/users?${params}`)
.then(r => setData(r.data))
.catch(() => {})
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, [page]);
async function toggleActive(userId) {
await api.put(`/admin/users/${userId}/toggle`);
load();
}
async function changeRole(userId, role) {
if (!confirm(`역할을 "${role}"로 변경하시겠습니까?`)) return;
await api.put(`/admin/users/${userId}/role`, { role });
load();
}
return (
<div className="container page">
<h2 style={{ marginBottom: 20 }}>사용자 관리</h2>
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
<input placeholder="이메일 또는 닉네임 검색" value={q}
onChange={e => setQ(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { setPage(1); load(); } }}
style={{ maxWidth: 280 }} />
<button className="btn btn-outline" onClick={() => { setPage(1); load(); }}>검색</button>
</div>
{loading ? <div className="spinner" /> : (
<>
<div className="table-wrap">
<table>
<thead>
<tr><th>이메일</th><th>닉네임</th><th>역할</th><th>상태</th><th>가입일</th><th>최근 로그인</th><th>역할 변경</th><th></th></tr>
</thead>
<tbody>
{data.users.map(u => (
<tr key={u.id}>
<td style={{ fontSize: 12 }}>{u.email}</td>
<td>{u.nickname}</td>
<td>
<span className={`badge badge-${u.role === 'admin' ? 'approved' : u.role === 'seller' ? 'pending' : 'draft'}`}>
{u.role}
</span>
</td>
<td>
<span style={{ color: u.isActive ? 'var(--success)' : 'var(--danger)', fontSize: 12 }}>
{u.isActive ? '활성' : '비활성'}
</span>
</td>
<td className="text-muted" style={{ fontSize: 12 }}>{new Date(u.createdAt).toLocaleDateString('ko-KR')}</td>
<td className="text-muted" style={{ fontSize: 12 }}>
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleDateString('ko-KR') : '-'}
</td>
<td>
<select defaultValue={u.role} onChange={e => changeRole(u.id, e.target.value)}
style={{ width: 90, padding: '4px 8px', fontSize: 12 }}>
<option value="buyer">buyer</option>
<option value="seller">seller</option>
<option value="admin">admin</option>
</select>
</td>
<td>
{u.role !== 'admin' && (
<button className={`btn btn-sm ${u.isActive ? 'btn-danger' : 'btn-success'}`}
onClick={() => toggleActive(u.id)}>
{u.isActive ? '비활성화' : '활성화'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.pages > 1 && (
<div className="pagination">
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
{Array.from({ length: data.pages }, (_, i) => (
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>{i + 1}</button>
))}
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
</div>
)}
</>
)}
</div>
);
}