- React+Vite frontend (dark theme, role-based routing: admin/seller/buyer) - Express+Prisma+PostgreSQL backend with JWT auth and audit logging - MinIO object storage with backend proxy for CORS-free firmware delivery - Mock payment flow (order → mock-pay → FlashToken) for pre-business testing - FlashToken lifecycle: issue → validate → esp-web-tools manifest → consume - Admin approval workflow for project/product submissions - Toss Payments integration guide (TOSS_PAYMENT_GUIDE.md) for live keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
246 lines
8.5 KiB
JavaScript
246 lines
8.5 KiB
JavaScript
const router = require('express').Router();
|
|
const { body, validationResult } = require('express-validator');
|
|
const prisma = require('../config/db');
|
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
|
const { writeAuditLog } = require('../middleware/audit');
|
|
|
|
// 모든 admin 라우트는 admin 역할 필요
|
|
router.use(requireAuth, requireRole('admin'));
|
|
|
|
// GET /api/admin/projects/pending
|
|
router.get('/projects/pending', async (_req, res) => {
|
|
try {
|
|
const projects = await prisma.project.findMany({
|
|
where: { status: 'pending' },
|
|
orderBy: { updatedAt: 'asc' },
|
|
include: {
|
|
user: { select: { id: true, email: true, nickname: true } },
|
|
files: { where: { fileType: 'image' }, take: 1 },
|
|
_count: { select: { files: true } },
|
|
},
|
|
});
|
|
res.json(projects);
|
|
} catch (err) {
|
|
res.status(500).json({ error: '서버 오류' });
|
|
}
|
|
});
|
|
|
|
// GET /api/admin/projects — 전체 목록 (필터)
|
|
router.get('/projects', async (req, res) => {
|
|
try {
|
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
const limit = Math.min(100, parseInt(req.query.limit) || 30);
|
|
const skip = (page - 1) * limit;
|
|
const where = {};
|
|
if (req.query.status) where.status = req.query.status;
|
|
|
|
const [projects, total] = await Promise.all([
|
|
prisma.project.findMany({
|
|
where, skip, take: limit,
|
|
orderBy: { updatedAt: 'desc' },
|
|
include: { user: { select: { id: true, email: true, nickname: true } } },
|
|
}),
|
|
prisma.project.count({ where }),
|
|
]);
|
|
res.json({ projects, total, page, pages: Math.ceil(total / limit) });
|
|
} catch (err) {
|
|
res.status(500).json({ error: '서버 오류' });
|
|
}
|
|
});
|
|
|
|
// POST /api/admin/projects/:id/approve
|
|
router.post('/projects/:id/approve', [
|
|
body('commissionRate').optional().isFloat({ min: 0, max: 1 }),
|
|
body('price').isInt({ min: 100 }),
|
|
], async (req, res) => {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) return res.status(400).json({ error: errors.array()[0].msg });
|
|
|
|
const { commissionRate = 0.1, price } = req.body;
|
|
try {
|
|
const project = await prisma.project.findUnique({ where: { id: req.params.id } });
|
|
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
|
|
if (project.status !== 'pending') return res.status(400).json({ error: '검토 대기 상태가 아닙니다' });
|
|
|
|
// 트랜잭션: 프로젝트 승인 + 상품 자동 생성
|
|
const [updated] = await prisma.$transaction([
|
|
prisma.project.update({
|
|
where: { id: req.params.id },
|
|
data: { status: 'approved', commissionRate: parseFloat(commissionRate), adminNote: null },
|
|
}),
|
|
prisma.product.upsert({
|
|
where: { projectId: req.params.id },
|
|
update: { price: parseInt(price), isOnSale: true },
|
|
create: { projectId: req.params.id, price: parseInt(price) },
|
|
}),
|
|
]);
|
|
|
|
await writeAuditLog({
|
|
userId: req.user.id, action: 'ADMIN_APPROVE',
|
|
targetType: 'Project', targetId: req.params.id,
|
|
req, metadata: { commissionRate, price }, responseStatus: 200,
|
|
});
|
|
res.json(updated);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: '서버 오류' });
|
|
}
|
|
});
|
|
|
|
// POST /api/admin/projects/:id/reject
|
|
router.post('/projects/:id/reject', [
|
|
body('adminNote').trim().isLength({ min: 5 }),
|
|
], async (req, res) => {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) return res.status(400).json({ error: errors.array()[0].msg });
|
|
|
|
try {
|
|
const project = await prisma.project.findUnique({ where: { id: req.params.id } });
|
|
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
|
|
|
|
const updated = await prisma.project.update({
|
|
where: { id: req.params.id },
|
|
data: { status: 'rejected', adminNote: req.body.adminNote },
|
|
});
|
|
|
|
await writeAuditLog({
|
|
userId: req.user.id, action: 'ADMIN_REJECT',
|
|
targetType: 'Project', targetId: req.params.id,
|
|
req, responseStatus: 200,
|
|
});
|
|
res.json(updated);
|
|
} catch (err) {
|
|
res.status(500).json({ error: '서버 오류' });
|
|
}
|
|
});
|
|
|
|
// GET /api/admin/users
|
|
router.get('/users', async (req, res) => {
|
|
try {
|
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
const limit = Math.min(100, parseInt(req.query.limit) || 30);
|
|
const skip = (page - 1) * limit;
|
|
const where = {};
|
|
if (req.query.role) where.role = req.query.role;
|
|
if (req.query.q) {
|
|
where.OR = [
|
|
{ email: { contains: req.query.q, mode: 'insensitive' } },
|
|
{ nickname: { contains: req.query.q, mode: 'insensitive' } },
|
|
];
|
|
}
|
|
|
|
const [users, total] = await Promise.all([
|
|
prisma.user.findMany({
|
|
where, skip, take: limit,
|
|
orderBy: { createdAt: 'desc' },
|
|
select: { id: true, email: true, nickname: true, role: true, isActive: true, createdAt: true, lastLoginAt: true },
|
|
}),
|
|
prisma.user.count({ where }),
|
|
]);
|
|
res.json({ users, total, page, pages: Math.ceil(total / limit) });
|
|
} catch (err) {
|
|
res.status(500).json({ error: '서버 오류' });
|
|
}
|
|
});
|
|
|
|
// PUT /api/admin/users/:id/toggle — 활성/비활성
|
|
router.put('/users/:id/toggle', async (req, res) => {
|
|
try {
|
|
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
|
|
if (!user) return res.status(404).json({ error: '사용자를 찾을 수 없습니다' });
|
|
if (user.role === 'admin') return res.status(400).json({ error: '관리자 계정은 비활성화할 수 없습니다' });
|
|
|
|
const updated = await prisma.user.update({
|
|
where: { id: req.params.id },
|
|
data: { isActive: !user.isActive },
|
|
select: { id: true, email: true, isActive: true },
|
|
});
|
|
await writeAuditLog({
|
|
userId: req.user.id, action: updated.isActive ? 'ADMIN_USER_ACTIVATE' : 'ADMIN_USER_DEACTIVATE',
|
|
targetType: 'User', targetId: req.params.id, req, responseStatus: 200,
|
|
});
|
|
res.json(updated);
|
|
} catch (err) {
|
|
res.status(500).json({ error: '서버 오류' });
|
|
}
|
|
});
|
|
|
|
// PUT /api/admin/users/:id/role — 역할 변경
|
|
router.put('/users/:id/role', [
|
|
body('role').isIn(['admin', 'seller', 'buyer']),
|
|
], async (req, res) => {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) return res.status(400).json({ error: errors.array()[0].msg });
|
|
|
|
try {
|
|
const updated = await prisma.user.update({
|
|
where: { id: req.params.id },
|
|
data: { role: req.body.role },
|
|
select: { id: true, email: true, role: true },
|
|
});
|
|
await writeAuditLog({
|
|
userId: req.user.id, action: 'ADMIN_ROLE_CHANGE',
|
|
targetType: 'User', targetId: req.params.id,
|
|
req, metadata: { newRole: req.body.role }, responseStatus: 200,
|
|
});
|
|
res.json(updated);
|
|
} catch (err) {
|
|
res.status(500).json({ error: '서버 오류' });
|
|
}
|
|
});
|
|
|
|
// GET /api/admin/logs
|
|
router.get('/logs', async (req, res) => {
|
|
try {
|
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
const limit = Math.min(200, parseInt(req.query.limit) || 50);
|
|
const skip = (page - 1) * limit;
|
|
const where = {};
|
|
if (req.query.action) where.action = req.query.action;
|
|
if (req.query.userId) where.userId = req.query.userId;
|
|
if (req.query.from || req.query.to) {
|
|
where.createdAt = {};
|
|
if (req.query.from) where.createdAt.gte = new Date(req.query.from);
|
|
if (req.query.to) where.createdAt.lte = new Date(req.query.to);
|
|
}
|
|
|
|
const [logs, total] = await Promise.all([
|
|
prisma.auditLog.findMany({
|
|
where, skip, take: limit,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: { user: { select: { email: true, nickname: true } } },
|
|
}),
|
|
prisma.auditLog.count({ where }),
|
|
]);
|
|
res.json({ logs, total, page, pages: Math.ceil(total / limit) });
|
|
} catch (err) {
|
|
res.status(500).json({ error: '서버 오류' });
|
|
}
|
|
});
|
|
|
|
// GET /api/admin/stats
|
|
router.get('/stats', async (_req, res) => {
|
|
try {
|
|
const [
|
|
totalUsers, totalProjects, pendingProjects,
|
|
totalOrders, paidOrders,
|
|
] = await Promise.all([
|
|
prisma.user.count(),
|
|
prisma.project.count(),
|
|
prisma.project.count({ where: { status: 'pending' } }),
|
|
prisma.order.count(),
|
|
prisma.order.aggregate({ where: { status: 'paid' }, _sum: { amount: true }, _count: true }),
|
|
]);
|
|
|
|
res.json({
|
|
users: totalUsers,
|
|
projects: { total: totalProjects, pending: pendingProjects },
|
|
revenue: { total: paidOrders._sum.amount || 0, orders: paidOrders._count },
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: '서버 오류' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|