Files
esp32DIY_web/backend/src/routes/admin.js
root 182782f271 feat: ESP32 DIY platform Phase 1 — marketplace with mock payment & flash token flow
- 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>
2026-05-20 06:43:08 +09:00

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;