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>
This commit is contained in:
245
backend/src/routes/admin.js
Normal file
245
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,245 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user