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,19 @@
FROM node:20-alpine
# ffmpeg (영상 압축, 2단계에서 사용)
RUN apk add --no-cache ffmpeg
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY prisma ./prisma
RUN npx prisma generate
COPY src ./src
EXPOSE 3201
# 마이그레이션 후 서버 시작
CMD ["sh", "-c", "npx prisma migrate deploy && node src/index.js"]

View File

@@ -0,0 +1,32 @@
{
"name": "platform-backend",
"version": "1.0.0",
"type": "commonjs",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"db:migrate": "prisma migrate deploy",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"postinstall": "prisma generate"
},
"dependencies": {
"@prisma/client": "^5.14.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^7.3.1",
"express-validator": "^7.1.0",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"minio": "^8.0.0",
"multer": "^1.4.5-lts.1",
"sharp": "^0.33.4",
"uuid": "^10.0.0"
},
"devDependencies": {
"nodemon": "^3.1.4",
"prisma": "^5.14.0"
}
}

View File

@@ -0,0 +1,202 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
admin
seller
buyer
}
enum ProjectStatus {
draft
pending
approved
rejected
suspended
}
enum OrderStatus {
pending
paid
cancelled
refunded
}
enum PaymentGateway {
toss
stripe
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
nickname String @unique
role UserRole @default(buyer)
profileImageUrl String?
isEmailVerified Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
lastLoginIp String?
projects Project[]
orders Order[]
reviews Review[]
auditLogs AuditLog[]
}
model Project {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
title String
description String @db.Text
difficultyLevel Int @default(3)
requiredParts Json?
status ProjectStatus @default(draft)
adminNote String?
commissionRate Float @default(0.1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
files ProjectFile[]
product Product?
}
model ProjectFile {
id String @id @default(uuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
fileType String
url String
thumbnailUrl String?
fileSize Int
mimeType String
originalName String?
displayOrder Int @default(0)
createdAt DateTime @default(now())
}
model Product {
id String @id @default(uuid())
projectId String @unique
project Project @relation(fields: [projectId], references: [id])
price Int
isOnSale Boolean @default(true)
totalSales Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
reviews Review[]
}
model Order {
id String @id @default(uuid())
buyerId String
buyer User @relation(fields: [buyerId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
amount Int
commissionAmount Int
sellerAmount Int
paymentGateway PaymentGateway @default(toss)
paymentKey String?
tossOrderId String? @unique
status OrderStatus @default(pending)
buyerInfo Json
deviceInfo Json?
orderedAt DateTime @default(now())
paidAt DateTime?
refundedAt DateTime?
refundReason String?
flashToken FlashToken?
review Review?
}
model FlashToken {
id String @id @default(uuid())
token String @unique @default(uuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id])
isUsed Boolean @default(false)
usedAt DateTime?
macAddress String?
chipFamily String?
expiresAt DateTime
createdAt DateTime @default(now())
flashLog FlashLog?
}
model FlashLog {
id String @id @default(uuid())
flashTokenId String @unique
flashToken FlashToken @relation(fields: [flashTokenId], references: [id])
macAddress String
chipFamily String
firmwareName String
firmwareId String
success Boolean
errorMessage String?
clientIp String
userAgent String?
flashedAt DateTime @default(now())
}
model Review {
id String @id @default(uuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
rating Int
title String
content String @db.Text
isVisible Boolean @default(true)
createdAt DateTime @default(now())
media ReviewMedia[]
}
model ReviewMedia {
id String @id @default(uuid())
reviewId String
review Review @relation(fields: [reviewId], references: [id], onDelete: Cascade)
mediaType String
url String
thumbnailUrl String?
createdAt DateTime @default(now())
}
model AuditLog {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id])
action String
targetType String?
targetId String?
ipAddress String
userAgent String?
requestMethod String?
requestPath String?
requestBody Json?
responseStatus Int?
metadata Json?
createdAt DateTime @default(now())
@@index([userId])
@@index([action])
@@index([createdAt])
}

View File

@@ -0,0 +1,7 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
module.exports = prisma;

View File

@@ -0,0 +1,40 @@
const Minio = require('minio');
const client = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_PORT || '9000'),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY,
secretKey: process.env.MINIO_SECRET_KEY,
});
const BUCKET = process.env.MINIO_BUCKET || 'platform';
async function ensureBucket() {
const exists = await client.bucketExists(BUCKET);
if (!exists) {
await client.makeBucket(BUCKET);
// 공개 읽기 정책 (이미지·영상 직접 접근용)
const policy = JSON.stringify({
Version: '2012-10-17',
Statement: [{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${BUCKET}/*`],
}],
});
await client.setBucketPolicy(BUCKET, policy);
console.log(`MinIO bucket "${BUCKET}" created`);
}
}
// 파일 업로드 후 공개 URL 반환
function publicUrl(objectName) {
const proto = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
const host = process.env.MINIO_ENDPOINT || 'localhost';
const port = process.env.MINIO_PORT || '9000';
return `${proto}://${host}:${port}/${BUCKET}/${objectName}`;
}
module.exports = { client, BUCKET, ensureBucket, publicUrl };

View File

@@ -0,0 +1,10 @@
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
maxRetriesPerRequest: 3,
lazyConnect: true,
});
redis.on('error', (err) => console.error('Redis error:', err.message));
module.exports = redis;

View File

@@ -0,0 +1,74 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { ensureBucket } = require('./config/minio');
const prisma = require('./config/db');
const app = express();
const PORT = process.env.PORT || 3201;
// ── 기본 미들웨어 ────────────────────────────────────────────────────────────
app.set('trust proxy', 1);
app.use(helmet());
app.use(cors({
origin: process.env.ALLOWED_ORIGIN || '*',
credentials: true,
}));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
// ── Rate Limiting ────────────────────────────────────────────────────────────
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 200,
standardHeaders: true,
legacyHeaders: false,
message: { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' },
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: { error: '로그인 시도가 너무 많습니다.' },
});
app.use('/api/', limiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
// ── 라우트 ───────────────────────────────────────────────────────────────────
app.use('/api/auth', require('./routes/auth'));
app.use('/api/projects', require('./routes/projects'));
app.use('/api/products', require('./routes/products'));
app.use('/api/admin', require('./routes/admin'));
// 헬스 체크
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// ── 오류 핸들러 ─────────────────────────────────────────────────────────────
app.use((err, _req, res, _next) => {
console.error(err);
res.status(err.status || 500).json({ error: err.message || '서버 오류' });
});
// ── 시작 ─────────────────────────────────────────────────────────────────────
async function start() {
try {
await prisma.$connect();
console.log('PostgreSQL connected');
await ensureBucket();
console.log('MinIO ready');
app.listen(PORT, () => {
console.log(`Platform Backend → http://localhost:${PORT}`);
});
} catch (err) {
console.error('Startup error:', err);
process.exit(1);
}
}
start();

View File

@@ -0,0 +1,37 @@
const prisma = require('../config/db');
const SENSITIVE = new Set(['password', 'passwordHash', 'token', 'paymentKey', 'secretKey']);
function maskBody(body) {
if (!body || typeof body !== 'object') return body;
const masked = { ...body };
for (const key of Object.keys(masked)) {
if (SENSITIVE.has(key)) masked[key] = '***';
}
return masked;
}
// 중요 행동에 명시적으로 호출하는 감사 로그 저장 함수
async function writeAuditLog({ userId, action, targetType, targetId, req, metadata, responseStatus }) {
try {
await prisma.auditLog.create({
data: {
userId: userId || null,
action,
targetType: targetType || null,
targetId: targetId || null,
ipAddress: req?.ip || req?.headers?.['x-forwarded-for'] || 'unknown',
userAgent: req?.headers?.['user-agent'] || null,
requestMethod: req?.method || null,
requestPath: req?.originalUrl || null,
requestBody: req?.body ? maskBody(req.body) : null,
responseStatus: responseStatus || null,
metadata: metadata || null,
},
});
} catch (err) {
console.error('AuditLog write failed:', err.message);
}
}
module.exports = { writeAuditLog };

View File

@@ -0,0 +1,33 @@
const jwt = require('jsonwebtoken');
const redis = require('../config/redis');
async function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: '인증이 필요합니다' });
}
const token = header.slice(7);
try {
// 블랙리스트 확인 (로그아웃된 토큰)
const blocked = await redis.get(`bl:${token}`);
if (blocked) return res.status(401).json({ error: '만료된 토큰입니다' });
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
req.token = token;
next();
} catch {
return res.status(401).json({ error: '유효하지 않은 토큰입니다' });
}
}
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user?.role)) {
return res.status(403).json({ error: '권한이 없습니다' });
}
next();
};
}
module.exports = { requireAuth, requireRole };

View 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;

View File

@@ -0,0 +1,134 @@
const router = require('express').Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const prisma = require('../config/db');
const redis = require('../config/redis');
const { requireAuth } = require('../middleware/auth');
const { writeAuditLog } = require('../middleware/audit');
function signToken(user) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role, nickname: user.nickname },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
}
// POST /api/auth/register
router.post('/register', [
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }),
body('nickname').trim().isLength({ min: 2, max: 30 }),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ error: errors.array()[0].msg });
const { email, password, nickname } = req.body;
try {
const exists = await prisma.user.findFirst({
where: { OR: [{ email }, { nickname }] },
});
if (exists) {
const field = exists.email === email ? '이메일' : '닉네임';
return res.status(409).json({ error: `이미 사용 중인 ${field}입니다` });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { email, passwordHash, nickname },
});
await writeAuditLog({ userId: user.id, action: 'REGISTER', req, responseStatus: 201 });
const token = signToken(user);
res.status(201).json({ token, user: { id: user.id, email: user.email, nickname: user.nickname, role: user.role } });
} catch (err) {
console.error(err);
res.status(500).json({ error: '서버 오류' });
}
});
// POST /api/auth/login
router.post('/login', [
body('email').isEmail().normalizeEmail(),
body('password').notEmpty(),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ error: errors.array()[0].msg });
const { email, password } = req.body;
try {
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
await writeAuditLog({ action: 'LOGIN_FAIL', req, metadata: { email }, responseStatus: 401 });
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다' });
}
if (!user.isActive) return res.status(403).json({ error: '비활성화된 계정입니다' });
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date(), lastLoginIp: req.ip },
});
await writeAuditLog({ userId: user.id, action: 'LOGIN', req, responseStatus: 200 });
const token = signToken(user);
res.json({ token, user: { id: user.id, email: user.email, nickname: user.nickname, role: user.role } });
} catch (err) {
console.error(err);
res.status(500).json({ error: '서버 오류' });
}
});
// POST /api/auth/logout
router.post('/logout', requireAuth, async (req, res) => {
try {
// 토큰 남은 TTL만큼 블랙리스트에 등록
const decoded = jwt.decode(req.token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) await redis.set(`bl:${req.token}`, '1', 'EX', ttl);
await writeAuditLog({ userId: req.user.id, action: 'LOGOUT', req, responseStatus: 200 });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
// GET /api/auth/me
router.get('/me', requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: { id: true, email: true, nickname: true, role: true, profileImageUrl: true, createdAt: true },
});
if (!user) return res.status(404).json({ error: '사용자를 찾을 수 없습니다' });
res.json(user);
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
// PUT /api/auth/me (프로필 업데이트)
router.put('/me', requireAuth, [
body('nickname').optional().trim().isLength({ min: 2, max: 30 }),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ error: errors.array()[0].msg });
const { nickname } = req.body;
try {
if (nickname) {
const dup = await prisma.user.findFirst({ where: { nickname, NOT: { id: req.user.id } } });
if (dup) return res.status(409).json({ error: '이미 사용 중인 닉네임입니다' });
}
const updated = await prisma.user.update({
where: { id: req.user.id },
data: { ...(nickname && { nickname }) },
select: { id: true, email: true, nickname: true, role: true },
});
res.json(updated);
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
module.exports = router;

View File

@@ -0,0 +1,114 @@
const router = require('express').Router();
const prisma = require('../config/db');
const { requireAuth, requireRole } = require('../middleware/auth');
// GET /api/products — 판매 중인 상품 목록
router.get('/', async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, parseInt(req.query.limit) || 20);
const skip = (page - 1) * limit;
const sortMap = {
'newest': { createdAt: 'desc' },
'popular': { totalSales: 'desc' },
'price_asc': { price: 'asc' },
'price_desc':{ price: 'desc' },
};
const orderBy = sortMap[req.query.sort] || sortMap.newest;
const [products, total] = await Promise.all([
prisma.product.findMany({
where: { isOnSale: true, project: { status: 'approved' } },
skip, take: limit, orderBy,
include: {
project: {
select: {
id: true, title: true, description: true, difficultyLevel: true,
user: { select: { nickname: true } },
files: { where: { fileType: 'image' }, take: 1, orderBy: { displayOrder: 'asc' } },
},
},
reviews: { where: { isVisible: true }, select: { rating: true } },
},
}),
prisma.product.count({ where: { isOnSale: true, project: { status: 'approved' } } }),
]);
// 평점 계산
const enriched = products.map(p => ({
...p,
avgRating: p.reviews.length
? +(p.reviews.reduce((s, r) => s + r.rating, 0) / p.reviews.length).toFixed(1)
: null,
reviewCount: p.reviews.length,
reviews: undefined,
}));
res.json({ products: enriched, total, page, pages: Math.ceil(total / limit) });
} catch (err) {
console.error(err);
res.status(500).json({ error: '서버 오류' });
}
});
// GET /api/products/:id — 상품 상세
router.get('/:id', async (req, res) => {
try {
const product = await prisma.product.findUnique({
where: { id: req.params.id },
include: {
project: {
include: {
user: { select: { nickname: true } },
files: { orderBy: { displayOrder: 'asc' } },
},
},
reviews: {
where: { isVisible: true },
orderBy: { createdAt: 'desc' },
take: 20,
include: {
user: { select: { nickname: true } },
media: true,
},
},
},
});
if (!product) return res.status(404).json({ error: '상품을 찾을 수 없습니다' });
const avgRating = product.reviews.length
? +(product.reviews.reduce((s, r) => s + r.rating, 0) / product.reviews.length).toFixed(1)
: null;
res.json({ ...product, avgRating, reviewCount: product.reviews.length });
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
// PUT /api/products/:id/toggle — 판매 일시중지/재개
router.put('/:id/toggle', requireAuth, async (req, res) => {
try {
const product = await prisma.product.findUnique({
where: { id: req.params.id },
include: { project: { select: { userId: true } } },
});
if (!product) return res.status(404).json({ error: '상품을 찾을 수 없습니다' });
const isOwner = product.project.userId === req.user.id;
if (!isOwner && req.user.role !== 'admin') {
return res.status(403).json({ error: '권한이 없습니다' });
}
const updated = await prisma.product.update({
where: { id: req.params.id },
data: { isOnSale: !product.isOnSale },
});
res.json(updated);
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
module.exports = router;

View File

@@ -0,0 +1,261 @@
const router = require('express').Router();
const multer = require('multer');
const { body, validationResult } = require('express-validator');
const prisma = require('../config/db');
const { requireAuth, requireRole } = require('../middleware/auth');
const { writeAuditLog } = require('../middleware/audit');
const { uploadImage, uploadRaw, deleteObject, IMAGE_TYPES } = require('../services/storage');
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 64 * 1024 * 1024 }, // 64 MB
});
const FILE_WHITELIST = new Set([
'image/jpeg', 'image/png', 'image/webp',
'application/octet-stream',
'application/pdf',
'model/stl', 'application/sla',
'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm',
]);
// ── 공개 라우트 ─────────────────────────────────────────────────────────────
// GET /api/projects — 승인된 프로젝트 목록
router.get('/', async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, parseInt(req.query.limit) || 20);
const skip = (page - 1) * limit;
const where = { status: 'approved' };
if (req.query.difficulty) where.difficultyLevel = parseInt(req.query.difficulty);
const [projects, total] = await Promise.all([
prisma.project.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, nickname: true } },
files: { where: { fileType: 'image' }, take: 1, orderBy: { displayOrder: 'asc' } },
product: { select: { id: true, price: true, isOnSale: true, totalSales: true } },
},
}),
prisma.project.count({ where }),
]);
res.json({ projects, total, page, pages: Math.ceil(total / limit) });
} catch (err) {
console.error(err);
res.status(500).json({ error: '서버 오류' });
}
});
// GET /api/projects/:id — 프로젝트 상세
router.get('/:id', async (req, res) => {
try {
const project = await prisma.project.findUnique({
where: { id: req.params.id },
include: {
user: { select: { id: true, nickname: true } },
files: { orderBy: { displayOrder: 'asc' } },
product: {
select: {
id: true, price: true, isOnSale: true, totalSales: true,
reviews: {
where: { isVisible: true },
select: { rating: true },
},
},
},
},
});
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
if (project.status !== 'approved') return res.status(403).json({ error: '비공개 프로젝트입니다' });
res.json(project);
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
// ── 인증 필요 라우트 ────────────────────────────────────────────────────────
// GET /api/projects/my — 내 프로젝트 목록
router.get('/my/list', requireAuth, async (req, res) => {
try {
const projects = await prisma.project.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
include: {
files: { where: { fileType: 'image' }, take: 1 },
product: { select: { id: true, price: true, totalSales: true } },
},
});
res.json(projects);
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
// POST /api/projects — 새 프로젝트 생성
router.post('/', requireAuth, [
body('title').trim().isLength({ min: 2, max: 100 }),
body('description').trim().isLength({ min: 10 }),
body('difficultyLevel').optional().isInt({ min: 1, max: 5 }),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ error: errors.array()[0].msg });
const { title, description, difficultyLevel, requiredParts } = req.body;
try {
const project = await prisma.project.create({
data: {
userId: req.user.id,
title,
description,
difficultyLevel: difficultyLevel ? parseInt(difficultyLevel) : 3,
requiredParts: requiredParts || [],
},
});
await writeAuditLog({ userId: req.user.id, action: 'PROJECT_CREATE', targetType: 'Project', targetId: project.id, req, responseStatus: 201 });
res.status(201).json(project);
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
// PUT /api/projects/:id — 프로젝트 수정 (draft 상태만)
router.put('/:id', requireAuth, async (req, res) => {
try {
const project = await prisma.project.findUnique({ where: { id: req.params.id } });
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
if (project.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: '권한이 없습니다' });
}
if (!['draft', 'rejected'].includes(project.status)) {
return res.status(400).json({ error: '승인 대기/완료 상태에서는 수정할 수 없습니다' });
}
const { title, description, difficultyLevel, requiredParts } = req.body;
const updated = await prisma.project.update({
where: { id: req.params.id },
data: {
...(title && { title }),
...(description && { description }),
...(difficultyLevel && { difficultyLevel: parseInt(difficultyLevel) }),
...(requiredParts !== undefined && { requiredParts }),
},
});
res.json(updated);
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
// DELETE /api/projects/:id
router.delete('/:id', requireAuth, async (req, res) => {
try {
const project = await prisma.project.findUnique({
where: { id: req.params.id },
include: { files: true },
});
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
if (project.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: '권한이 없습니다' });
}
// MinIO 파일 삭제
await Promise.all(project.files.map(f => deleteObject(f.url)));
await prisma.project.delete({ where: { id: req.params.id } });
await writeAuditLog({ userId: req.user.id, action: 'PROJECT_DELETE', targetType: 'Project', targetId: req.params.id, req, responseStatus: 200 });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
// POST /api/projects/:id/submit — 검토 요청
router.post('/:id/submit', requireAuth, async (req, res) => {
try {
const project = await prisma.project.findUnique({ where: { id: req.params.id } });
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
if (project.userId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' });
if (!['draft', 'rejected'].includes(project.status)) {
return res.status(400).json({ error: '이미 검토 요청 중이거나 승인된 프로젝트입니다' });
}
const updated = await prisma.project.update({
where: { id: req.params.id },
data: { status: 'pending', adminNote: null },
});
await writeAuditLog({ userId: req.user.id, action: 'PROJECT_SUBMIT', targetType: 'Project', targetId: project.id, req, responseStatus: 200 });
res.json(updated);
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
// POST /api/projects/:id/files — 파일 업로드
router.post('/:id/files', requireAuth, upload.array('files', 10), async (req, res) => {
try {
const project = await prisma.project.findUnique({ where: { id: req.params.id } });
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
if (project.userId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' });
if (!req.files?.length) return res.status(400).json({ error: '파일이 없습니다' });
const fileType = req.body.fileType || 'image'; // image|video|stl|wiring|firmware
const results = [];
for (const file of req.files) {
let stored;
if (IMAGE_TYPES.has(file.mimetype)) {
stored = await uploadImage(file.buffer, file.originalname, `projects/${project.id}`);
} else {
stored = await uploadRaw(file.buffer, file.originalname, file.mimetype, `projects/${project.id}`);
}
const pf = await prisma.projectFile.create({
data: {
projectId: project.id,
fileType,
url: stored.url,
thumbnailUrl: stored.thumbnailUrl || null,
fileSize: stored.fileSize,
mimeType: stored.mimeType,
originalName: file.originalname,
displayOrder: results.length,
},
});
results.push(pf);
}
res.status(201).json(results);
} catch (err) {
console.error(err);
res.status(500).json({ error: '파일 업로드 실패: ' + err.message });
}
});
// DELETE /api/projects/:id/files/:fileId
router.delete('/:id/files/:fileId', requireAuth, async (req, res) => {
try {
const file = await prisma.projectFile.findUnique({ where: { id: req.params.fileId } });
if (!file || file.projectId !== req.params.id) return res.status(404).json({ error: '파일을 찾을 수 없습니다' });
const project = await prisma.project.findUnique({ where: { id: req.params.id } });
if (project.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: '권한이 없습니다' });
}
await deleteObject(file.url);
if (file.thumbnailUrl) await deleteObject(file.thumbnailUrl);
await prisma.projectFile.delete({ where: { id: req.params.fileId } });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
module.exports = router;

View File

@@ -0,0 +1,22 @@
// 관리자 계정 초기 생성 스크립트
// 사용: node src/scripts/createAdmin.js admin@example.com password123 관리자
const bcrypt = require('bcryptjs');
const prisma = require('../config/db');
async function main() {
const [,, email, password, nickname] = process.argv;
if (!email || !password || !nickname) {
console.error('사용법: node src/scripts/createAdmin.js <email> <password> <nickname>');
process.exit(1);
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.upsert({
where: { email },
update: { passwordHash, role: 'admin', nickname },
create: { email, passwordHash, nickname, role: 'admin', isEmailVerified: true },
});
console.log(`관리자 계정 생성/업데이트: ${user.email} (${user.id})`);
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,68 @@
const { client, BUCKET, publicUrl } = require('../config/minio');
const sharp = require('sharp');
const { v4: uuidv4 } = require('uuid');
const path = require('path');
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
const BIN_TYPES = new Set(['application/octet-stream']);
// 이미지 업로드: WebP 변환 + 리사이즈
async function uploadImage(buffer, originalName, folder = 'images') {
const id = uuidv4();
const objectName = `${folder}/${id}.webp`;
const thumbName = `${folder}/${id}_thumb.webp`;
const [main, thumb] = await Promise.all([
sharp(buffer)
.resize(1920, 1080, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer(),
sharp(buffer)
.resize(400, 300, { fit: 'cover' })
.webp({ quality: 70 })
.toBuffer(),
]);
await Promise.all([
client.putObject(BUCKET, objectName, main, main.length, { 'Content-Type': 'image/webp' }),
client.putObject(BUCKET, thumbName, thumb, thumb.length, { 'Content-Type': 'image/webp' }),
]);
return {
url: publicUrl(objectName),
thumbnailUrl: publicUrl(thumbName),
fileSize: main.length,
mimeType: 'image/webp',
};
}
// 일반 파일 업로드 (bin, stl, pdf 등)
async function uploadRaw(buffer, originalName, mimeType, folder = 'files') {
const ext = path.extname(originalName).toLowerCase();
const id = uuidv4();
const objectName = `${folder}/${id}${ext}`;
await client.putObject(BUCKET, objectName, buffer, buffer.length, {
'Content-Type': mimeType || 'application/octet-stream',
});
return {
url: publicUrl(objectName),
fileSize: buffer.length,
mimeType: mimeType || 'application/octet-stream',
};
}
// 오브젝트 삭제
async function deleteObject(url) {
try {
// URL에서 object name 추출: http://host:port/bucket/path/to/file
const parts = new URL(url).pathname.split('/').slice(2); // bucket 이후
const objectName = parts.join('/');
await client.removeObject(BUCKET, objectName);
} catch (err) {
console.error('MinIO delete error:', err.message);
}
}
module.exports = { uploadImage, uploadRaw, deleteObject, IMAGE_TYPES };