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:
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN apk add --no-cache ffmpeg openssl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma
|
||||
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
EXPOSE 3201
|
||||
|
||||
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && node src/index.js"]
|
||||
32
backend/package.json
Normal file
32
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
204
backend/prisma/schema.prisma
Normal file
204
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,204 @@
|
||||
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)
|
||||
chipFamily String @default("ESP32-S3")
|
||||
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?
|
||||
flashOffset String @default("0x0")
|
||||
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])
|
||||
}
|
||||
7
backend/src/config/db.js
Normal file
7
backend/src/config/db.js
Normal 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;
|
||||
28
backend/src/config/minio.js
Normal file
28
backend/src/config/minio.js
Normal file
@@ -0,0 +1,28 @@
|
||||
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);
|
||||
console.log(`MinIO bucket "${BUCKET}" created`);
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 URL — 플랫폼 백엔드 프록시를 통해 서빙 (CORS 문제 없음)
|
||||
// objectName: "images/uuid.webp" 형식
|
||||
function publicUrl(objectName) {
|
||||
const base = (process.env.BASE_URL || 'http://localhost:3200').replace(/\/$/, '');
|
||||
return `${base}/api/files/${objectName}`;
|
||||
}
|
||||
|
||||
module.exports = { client, BUCKET, ensureBucket, publicUrl };
|
||||
10
backend/src/config/redis.js
Normal file
10
backend/src/config/redis.js
Normal 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;
|
||||
107
backend/src/index.js
Normal file
107
backend/src/index.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const path = require('path');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { ensureBucket, client, BUCKET } = 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({ crossOriginResourcePolicy: false }));
|
||||
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,
|
||||
max: 300,
|
||||
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);
|
||||
|
||||
// ── MinIO 파일 프록시 ─────────────────────────────────────────────────────────
|
||||
// /api/files/images/xxx.webp → MinIO BUCKET/images/xxx.webp 스트리밍
|
||||
// esp-web-tools가 bin 파일을 직접 fetch할 때 CORS 헤더 포함
|
||||
const MIME = {
|
||||
'.webp': 'image/webp', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
||||
'.bin': 'application/octet-stream', '.stl': 'model/stl',
|
||||
'.pdf': 'application/pdf', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
||||
};
|
||||
|
||||
app.get('/api/files/*', async (req, res) => {
|
||||
try {
|
||||
const objectName = req.params[0];
|
||||
if (!objectName) return res.status(400).end();
|
||||
|
||||
const ext = path.extname(objectName).toLowerCase();
|
||||
const contentType = MIME[ext] || 'application/octet-stream';
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
const stream = await client.getObject(BUCKET, objectName);
|
||||
stream.pipe(res);
|
||||
stream.on('error', () => res.status(404).end());
|
||||
} catch {
|
||||
res.status(404).end();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 라우트 ───────────────────────────────────────────────────────────────────
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
app.use('/api/projects', require('./routes/projects'));
|
||||
app.use('/api/products', require('./routes/products'));
|
||||
app.use('/api/orders', require('./routes/orders'));
|
||||
app.use('/api/flash', require('./routes/flash'));
|
||||
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();
|
||||
37
backend/src/middleware/audit.js
Normal file
37
backend/src/middleware/audit.js
Normal 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 };
|
||||
33
backend/src/middleware/auth.js
Normal file
33
backend/src/middleware/auth.js
Normal 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 };
|
||||
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;
|
||||
134
backend/src/routes/auth.js
Normal file
134
backend/src/routes/auth.js
Normal 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;
|
||||
142
backend/src/routes/flash.js
Normal file
142
backend/src/routes/flash.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const router = require('express').Router();
|
||||
const prisma = require('../config/db');
|
||||
const { writeAuditLog } = require('../middleware/audit');
|
||||
|
||||
// FlashToken 포함 조회 헬퍼
|
||||
async function findToken(token) {
|
||||
return prisma.flashToken.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
order: {
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true, title: true, chipFamily: true,
|
||||
files: {
|
||||
where: { fileType: 'firmware' },
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/flash/:token — 토큰 유효성 확인 (Flash 페이지, webflash 둘 다 사용)
|
||||
router.get('/:token', async (req, res) => {
|
||||
try {
|
||||
const ft = await findToken(req.params.token);
|
||||
if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' });
|
||||
|
||||
const expired = new Date() > new Date(ft.expiresAt);
|
||||
const project = ft.order.product.project;
|
||||
|
||||
res.json({
|
||||
valid: !expired && !ft.isUsed,
|
||||
isUsed: ft.isUsed,
|
||||
expired,
|
||||
expiresAt: ft.expiresAt,
|
||||
productName: project.title,
|
||||
chipFamily: project.chipFamily,
|
||||
hasFirmware: project.files.length > 0,
|
||||
usedAt: ft.usedAt,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: '서버 오류' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/flash/:token/manifest — esp-web-tools 매니페스트 반환
|
||||
// 브라우저에서 직접 fetch하므로 CORS 허용
|
||||
router.get('/:token/manifest', async (req, res) => {
|
||||
try {
|
||||
const ft = await findToken(req.params.token);
|
||||
if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' });
|
||||
|
||||
if (ft.isUsed) {
|
||||
return res.status(410).json({ error: '이미 플래시에 사용된 토큰입니다' });
|
||||
}
|
||||
if (new Date() > new Date(ft.expiresAt)) {
|
||||
return res.status(410).json({ error: '만료된 토큰입니다' });
|
||||
}
|
||||
|
||||
const project = ft.order.product.project;
|
||||
if (!project.files.length) {
|
||||
return res.status(404).json({ error: '펌웨어 파일이 없습니다. 판매자에게 문의하세요.' });
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
name: project.title,
|
||||
new_install_improv_wait_ms: 0,
|
||||
builds: [{
|
||||
chipFamily: project.chipFamily,
|
||||
parts: project.files.map(f => ({
|
||||
path: f.url,
|
||||
offset: parseInt(f.flashOffset || '0x0', 16),
|
||||
})),
|
||||
}],
|
||||
};
|
||||
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.json(manifest);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: '서버 오류' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/flash/:token/consume — 플래시 완료 기록
|
||||
// esp-web-tools 또는 플래시 페이지에서 호출
|
||||
router.post('/:token/consume', async (req, res) => {
|
||||
try {
|
||||
const ft = await findToken(req.params.token);
|
||||
if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' });
|
||||
if (ft.isUsed) return res.status(410).json({ error: '이미 사용된 토큰입니다' });
|
||||
if (new Date() > new Date(ft.expiresAt)) return res.status(410).json({ error: '만료된 토큰입니다' });
|
||||
|
||||
const { mac = 'unknown', chipFamily, success = true, errorMessage } = req.body;
|
||||
const project = ft.order.product.project;
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.flashToken.update({
|
||||
where: { id: ft.id },
|
||||
data: { isUsed: true, usedAt: new Date(), macAddress: mac, chipFamily: chipFamily || project.chipFamily },
|
||||
}),
|
||||
prisma.flashLog.create({
|
||||
data: {
|
||||
flashTokenId: ft.id,
|
||||
macAddress: mac,
|
||||
chipFamily: chipFamily || project.chipFamily,
|
||||
firmwareName: project.title,
|
||||
firmwareId: project.id,
|
||||
success: Boolean(success),
|
||||
errorMessage: errorMessage || null,
|
||||
clientIp: req.ip || 'unknown',
|
||||
userAgent: req.headers['user-agent'] || null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await writeAuditLog({
|
||||
userId: ft.order.buyerId,
|
||||
action: success ? 'FLASH_SUCCESS' : 'FLASH_FAIL',
|
||||
targetType: 'FlashToken', targetId: ft.id,
|
||||
req, metadata: { mac, chipFamily, firmwareId: project.id }, responseStatus: 200,
|
||||
});
|
||||
|
||||
res.json({ success: true, message: success ? '플래시 완료 기록됨' : '실패 기록됨' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: '서버 오류' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
195
backend/src/routes/orders.js
Normal file
195
backend/src/routes/orders.js
Normal file
@@ -0,0 +1,195 @@
|
||||
const router = require('express').Router();
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const prisma = require('../config/db');
|
||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||
const { writeAuditLog } = require('../middleware/audit');
|
||||
|
||||
// POST /api/orders — 주문 생성 (결제 전)
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
const { productId } = req.body;
|
||||
if (!productId) return res.status(400).json({ error: 'productId가 필요합니다' });
|
||||
|
||||
try {
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: productId },
|
||||
include: { project: { select: { commissionRate: true, userId: true } } },
|
||||
});
|
||||
if (!product) return res.status(404).json({ error: '상품을 찾을 수 없습니다' });
|
||||
if (!product.isOnSale) return res.status(400).json({ error: '판매 중지된 상품입니다' });
|
||||
if (product.project.userId === req.user.id) return res.status(400).json({ error: '자신의 상품은 구매할 수 없습니다' });
|
||||
|
||||
// 이미 유효한 주문이 있는지 확인 (중복 구매 방지)
|
||||
const existing = await prisma.order.findFirst({
|
||||
where: { buyerId: req.user.id, productId, status: { in: ['pending', 'paid'] } },
|
||||
include: { flashToken: true },
|
||||
});
|
||||
if (existing?.status === 'paid') {
|
||||
return res.status(409).json({
|
||||
error: '이미 구매한 상품입니다',
|
||||
flashToken: existing.flashToken?.token,
|
||||
orderId: existing.id,
|
||||
});
|
||||
}
|
||||
if (existing?.status === 'pending') {
|
||||
return res.json({ orderId: existing.id, amount: existing.amount, status: 'pending' });
|
||||
}
|
||||
|
||||
const commissionAmount = Math.round(product.price * product.project.commissionRate);
|
||||
const sellerAmount = product.price - commissionAmount;
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
buyerId: req.user.id,
|
||||
productId,
|
||||
amount: product.price,
|
||||
commissionAmount,
|
||||
sellerAmount,
|
||||
paymentGateway: 'toss',
|
||||
tossOrderId: `mock_${uuidv4()}`,
|
||||
buyerInfo: { email: req.user.email, nickname: req.user.nickname },
|
||||
deviceInfo: { ip: req.ip, userAgent: req.headers['user-agent'] },
|
||||
},
|
||||
});
|
||||
|
||||
await writeAuditLog({ userId: req.user.id, action: 'ORDER_CREATE', targetType: 'Order', targetId: order.id, req, responseStatus: 201 });
|
||||
res.status(201).json({ orderId: order.id, amount: order.amount, status: 'pending' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: '서버 오류' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/orders/:id/mock-pay — 모의 결제 (사업자 등록 전 테스트용)
|
||||
// 실제 결제 없이 즉시 paid 처리하고 FlashToken 발급
|
||||
router.post('/:id/mock-pay', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { flashToken: true },
|
||||
});
|
||||
if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' });
|
||||
if (order.buyerId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' });
|
||||
if (order.status === 'paid') {
|
||||
return res.json({ flashToken: order.flashToken?.token, orderId: order.id, alreadyPaid: true });
|
||||
}
|
||||
if (order.status !== 'pending') {
|
||||
return res.status(400).json({ error: `${order.status} 상태의 주문은 결제할 수 없습니다` });
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30일
|
||||
|
||||
const [updatedOrder, flashToken] = await prisma.$transaction([
|
||||
prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { status: 'paid', paidAt: new Date(), paymentKey: `mock_${uuidv4()}` },
|
||||
}),
|
||||
prisma.flashToken.create({
|
||||
data: { orderId: order.id, expiresAt },
|
||||
}),
|
||||
prisma.product.update({
|
||||
where: { id: order.productId },
|
||||
data: { totalSales: { increment: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
await writeAuditLog({
|
||||
userId: req.user.id, action: 'MOCK_PAYMENT',
|
||||
targetType: 'Order', targetId: order.id,
|
||||
req, metadata: { amount: order.amount, mode: 'mock' }, responseStatus: 200,
|
||||
});
|
||||
|
||||
res.json({ flashToken: flashToken.token, orderId: order.id, expiresAt: flashToken.expiresAt });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: '서버 오류' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/orders/me — 내 주문 목록
|
||||
router.get('/me', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { buyerId: req.user.id },
|
||||
orderBy: { orderedAt: 'desc' },
|
||||
include: {
|
||||
product: {
|
||||
select: {
|
||||
id: true, price: true,
|
||||
project: { select: { title: true, chipFamily: true, files: { where: { fileType: 'image' }, take: 1 } } },
|
||||
},
|
||||
},
|
||||
flashToken: { select: { token: true, isUsed: true, expiresAt: true } },
|
||||
review: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
res.json(orders);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: '서버 오류' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/orders/:id
|
||||
router.get('/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
product: { include: { project: true } },
|
||||
flashToken: true,
|
||||
},
|
||||
});
|
||||
if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' });
|
||||
if (order.buyerId !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '권한이 없습니다' });
|
||||
}
|
||||
res.json(order);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: '서버 오류' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/orders/:id/refund — 환불 (모의 / 플래시 미완료 시만)
|
||||
router.post('/:id/refund', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { flashToken: true },
|
||||
});
|
||||
if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' });
|
||||
if (order.buyerId !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: '권한이 없습니다' });
|
||||
}
|
||||
if (order.status !== 'paid') return res.status(400).json({ error: '결제 완료 상태가 아닙니다' });
|
||||
if (order.flashToken?.isUsed && req.user.role !== 'admin') {
|
||||
return res.status(400).json({ error: '플래시가 완료된 주문은 환불할 수 없습니다' });
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { status: 'refunded', refundedAt: new Date(), refundReason: req.body.reason || '사용자 요청' },
|
||||
}),
|
||||
// FlashToken 무효화
|
||||
...(order.flashToken ? [prisma.flashToken.update({
|
||||
where: { id: order.flashToken.id },
|
||||
data: { expiresAt: new Date() },
|
||||
})] : []),
|
||||
prisma.product.update({
|
||||
where: { id: order.productId },
|
||||
data: { totalSales: { decrement: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
await writeAuditLog({
|
||||
userId: req.user.id, action: 'REFUND',
|
||||
targetType: 'Order', targetId: order.id,
|
||||
req, responseStatus: 200,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: '서버 오류' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
114
backend/src/routes/products.js
Normal file
114
backend/src/routes/products.js
Normal 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;
|
||||
265
backend/src/routes/projects.js
Normal file
265
backend/src/routes/projects.js
Normal file
@@ -0,0 +1,265 @@
|
||||
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, chipFamily, requiredParts } = req.body;
|
||||
try {
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
title,
|
||||
description,
|
||||
difficultyLevel: difficultyLevel ? parseInt(difficultyLevel) : 3,
|
||||
chipFamily: chipFamily || 'ESP32-S3',
|
||||
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, chipFamily, requiredParts } = req.body;
|
||||
const updated = await prisma.project.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...(title && { title }),
|
||||
...(description && { description }),
|
||||
...(difficultyLevel && { difficultyLevel: parseInt(difficultyLevel) }),
|
||||
...(chipFamily && { chipFamily }),
|
||||
...(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 flashOffset = req.body.flashOffset || '0x0';
|
||||
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,
|
||||
flashOffset: fileType === 'firmware' ? flashOffset : '0x0',
|
||||
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;
|
||||
22
backend/src/scripts/createAdmin.js
Normal file
22
backend/src/scripts/createAdmin.js
Normal 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); });
|
||||
68
backend/src/services/storage.js
Normal file
68
backend/src/services/storage.js
Normal 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 };
|
||||
Reference in New Issue
Block a user