feat: mock payment flow + flash token page (test mode)

- Mock purchase: order create → mock-pay → FlashToken issued instantly (no real billing)
- Flash page (/flash/:token): esp-web-tools integration, token state display, consume on complete
- Orders route: create/mock-pay/me/refund with full audit logging
- Flash route: GET validate, GET manifest (esp-web-tools compatible), POST consume
- MinIO file proxy (/api/files/*): browser CORS solved, firmware served through backend
- Schema: chipFamily on Project, flashOffset on ProjectFile
- ProjectNew: chipFamily selector + firmware flash offset option
- MyOrders: real order list with flash token status and buttons
- Dockerfile: prisma db push (no migration files needed for dev)
- TOSS_PAYMENT_GUIDE.md: step-by-step guide for real payment after business registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-05-20 06:18:19 +09:00
parent bdef4b7ae0
commit 9a9967bed8
15 changed files with 1071 additions and 66 deletions

View File

@@ -15,5 +15,5 @@ COPY src ./src
EXPOSE 3201
# 마이그레이션 후 서버 시작
CMD ["sh", "-c", "npx prisma migrate deploy && node src/index.js"]
# prisma db push: 마이그레이션 파일 없이 스키마를 DB에 직접 동기화 (개발용)
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && node src/index.js"]

View File

@@ -60,6 +60,7 @@ model Project {
title String
description String @db.Text
difficultyLevel Int @default(3)
chipFamily String @default("ESP32-S3")
requiredParts Json?
status ProjectStatus @default(draft)
adminNote String?
@@ -81,6 +82,7 @@ model ProjectFile {
fileSize Int
mimeType String
originalName String?
flashOffset String @default("0x0")
displayOrder Int @default(0)
createdAt DateTime @default(now())
}

View File

@@ -14,27 +14,15 @@ 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 반환
// 파일 URL — 플랫폼 백엔드 프록시를 통해 서빙 (CORS 문제 없음)
// objectName: "images/uuid.webp" 형식
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}`;
const base = (process.env.BASE_URL || 'http://localhost:3200').replace(/\/$/, '');
return `${base}/api/files/${objectName}`;
}
module.exports = { client, BUCKET, ensureBucket, publicUrl };

View File

@@ -1,8 +1,9 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const path = require('path');
const rateLimit = require('express-rate-limit');
const { ensureBucket } = require('./config/minio');
const { ensureBucket, client, BUCKET } = require('./config/minio');
const prisma = require('./config/db');
const app = express();
@@ -10,7 +11,7 @@ const PORT = process.env.PORT || 3201;
// ── 기본 미들웨어 ────────────────────────────────────────────────────────────
app.set('trust proxy', 1);
app.use(helmet());
app.use(helmet({ crossOriginResourcePolicy: false }));
app.use(cors({
origin: process.env.ALLOWED_ORIGIN || '*',
credentials: true,
@@ -20,8 +21,8 @@ app.use(express.urlencoded({ extended: true, limit: '2mb' }));
// ── Rate Limiting ────────────────────────────────────────────────────────────
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 200,
windowMs: 15 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false,
message: { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' },
@@ -33,13 +34,45 @@ const authLimiter = rateLimit({
});
app.use('/api/', limiter);
app.use('/api/auth/login', authLimiter);
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'));
// 헬스 체크

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

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

View File

@@ -108,7 +108,7 @@ router.post('/', requireAuth, [
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ error: errors.array()[0].msg });
const { title, description, difficultyLevel, requiredParts } = req.body;
const { title, description, difficultyLevel, chipFamily, requiredParts } = req.body;
try {
const project = await prisma.project.create({
data: {
@@ -116,6 +116,7 @@ router.post('/', requireAuth, [
title,
description,
difficultyLevel: difficultyLevel ? parseInt(difficultyLevel) : 3,
chipFamily: chipFamily || 'ESP32-S3',
requiredParts: requiredParts || [],
},
});
@@ -138,13 +139,14 @@ router.put('/:id', requireAuth, async (req, res) => {
return res.status(400).json({ error: '승인 대기/완료 상태에서는 수정할 수 없습니다' });
}
const { title, description, difficultyLevel, requiredParts } = req.body;
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 }),
},
});
@@ -205,7 +207,8 @@ router.post('/:id/files', requireAuth, upload.array('files', 10), async (req, re
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 fileType = req.body.fileType || 'image'; // image|video|stl|wiring|firmware
const flashOffset = req.body.flashOffset || '0x0';
const results = [];
for (const file of req.files) {
@@ -225,6 +228,7 @@ router.post('/:id/files', requireAuth, upload.array('files', 10), async (req, re
fileSize: stored.fileSize,
mimeType: stored.mimeType,
originalName: file.originalname,
flashOffset: fileType === 'firmware' ? flashOffset : '0x0',
displayOrder: results.length,
},
});