From 9a9967bed8efbde8a9054c1221bd77a18f5733b8 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 20 May 2026 06:18:19 +0900 Subject: [PATCH] feat: mock payment flow + flash token page (test mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- platform/.env.example | 18 +- platform/TOSS_PAYMENT_GUIDE.md | 267 ++++++++++++++++++ platform/backend/Dockerfile | 4 +- platform/backend/prisma/schema.prisma | 2 + platform/backend/src/config/minio.js | 20 +- platform/backend/src/index.js | 43 ++- platform/backend/src/routes/flash.js | 142 ++++++++++ platform/backend/src/routes/orders.js | 195 +++++++++++++ platform/backend/src/routes/projects.js | 10 +- platform/frontend/index.html | 1 + platform/frontend/src/App.jsx | 2 + .../frontend/src/pages/Dashboard/MyOrders.jsx | 99 ++++++- .../src/pages/Dashboard/ProjectNew.jsx | 60 +++- platform/frontend/src/pages/Flash.jsx | 184 ++++++++++++ platform/frontend/src/pages/ProductDetail.jsx | 90 ++++-- 15 files changed, 1071 insertions(+), 66 deletions(-) create mode 100644 platform/TOSS_PAYMENT_GUIDE.md create mode 100644 platform/backend/src/routes/flash.js create mode 100644 platform/backend/src/routes/orders.js create mode 100644 platform/frontend/src/pages/Flash.jsx diff --git a/platform/.env.example b/platform/.env.example index a821752..5bee44c 100644 --- a/platform/.env.example +++ b/platform/.env.example @@ -1,3 +1,8 @@ +# ────────────────────────────────────────────────────── +# ESP32 DIY 플랫폼 환경 변수 +# cp .env.example .env 후 각 값을 채워주세요 +# ────────────────────────────────────────────────────── + # PostgreSQL DB_PASSWORD=change_this_strong_password @@ -8,12 +13,17 @@ MINIO_PASSWORD=change_this_strong_password # JWT (openssl rand -hex 64 로 생성 권장) JWT_SECRET=change_this_very_long_random_secret_string -# 배포 URL (DNS 설정 후 변경) +# 서비스 외부 접근 URL (DNS 설정 후 변경, 파일 URL에 사용됨) +# 로컬 테스트: http://localhost:3200 +# 실 서비스: https://your-domain.com BASE_URL=http://localhost:3200 -# 토스페이먼츠 (https://developers.tosspayments.com 에서 발급) -TOSS_CLIENT_KEY=test_ck_YOUR_KEY -TOSS_SECRET_KEY=test_sk_YOUR_KEY +# ────────────────────────────────────────────────────── +# 토스페이먼츠 (현재 테스트 모드 — 값 없어도 동작함) +# 실결제 전환 시 TOSS_PAYMENT_GUIDE.md 참고 +# ────────────────────────────────────────────────────── +TOSS_CLIENT_KEY=test_ck_placeholder +TOSS_SECRET_KEY=test_sk_placeholder # webflash 연동 내부 토큰 (임의 UUID) WEBFLASH_INTERNAL_TOKEN=change_this_token diff --git a/platform/TOSS_PAYMENT_GUIDE.md b/platform/TOSS_PAYMENT_GUIDE.md new file mode 100644 index 0000000..c5156fd --- /dev/null +++ b/platform/TOSS_PAYMENT_GUIDE.md @@ -0,0 +1,267 @@ +# 토스페이먼츠 실전 적용 가이드 + +> 현재 플랫폼은 **테스트(모의 결제) 모드**로 동작합니다. +> 사업자 등록 완료 후 아래 절차를 따라 실제 결제로 전환하세요. + +--- + +## 1단계 — 사업자 등록 + +토스페이먼츠는 사업자 등록증이 필요합니다. + +| 구분 | 내용 | +|------|------| +| 개인사업자 | 홈택스에서 사업자 등록 (즉시 발급 가능) | +| 법인사업자 | 법인 설립 후 사업자 등록 | +| 필요 서류 | 사업자등록증, 통장 사본, 신분증 | + +--- + +## 2단계 — 토스페이먼츠 계정 및 API 키 발급 + +### 2-1. 개발자 센터 가입 + +1. [https://developers.tosspayments.com](https://developers.tosspayments.com) 접속 +2. 회원가입 후 **내 개발 정보** → **API 키** 탭 +3. **테스트 키** (무료, 사업자 없어도 사용 가능): + - `test_ck_...` — 클라이언트 키 + - `test_sk_...` — 시크릿 키 + +### 2-2. 실 결제 키 발급 (사업자 등록 후) + +1. 토스페이먼츠 파트너 신청: [https://www.tosspayments.com/contact](https://www.tosspayments.com/contact) +2. 심사 완료 후 **라이브 키** 발급: + - `live_ck_...` — 클라이언트 키 + - `live_sk_...` — 시크릿 키 +3. 수수료: 카드 기준 약 **2.2%** (매출 규모에 따라 협의 가능) + +--- + +## 3단계 — 플랫폼에 토스 SDK 적용 + +### 3-1. 환경 변수 업데이트 + +```env +# platform/.env +TOSS_CLIENT_KEY=live_ck_YOUR_LIVE_KEY # test_ck_ → live_ck_ 로 교체 +TOSS_SECRET_KEY=live_sk_YOUR_LIVE_KEY # test_sk_ → live_sk_ 로 교체 +BASE_URL=https://your-domain.com # 실제 도메인으로 변경 +``` + +### 3-2. 프론트엔드 — 토스 SDK 로드 (`platform/frontend/index.html`) + +```html + + + + + +``` + +### 3-3. 프론트엔드 — 결제창 호출 (`ProductDetail.jsx`) + +현재 `handleBuy` 함수의 `mock-pay` 부분을 아래로 교체하세요: + +```javascript +// ❌ 기존 모의 결제 코드 +const payRes = await api.post(`/orders/${orderId}/mock-pay`); +navigate(`/flash/${payRes.data.flashToken}`); + +// ✅ 토스 실결제 코드로 교체 +const tossPayments = TossPayments(import.meta.env.VITE_TOSS_CLIENT_KEY); +const payment = tossPayments.payment({ customerKey: user.id }); + +await payment.requestPayment({ + method: 'CARD', + amount: { + currency: 'KRW', + value: product.price, + }, + orderId, // 서버에서 생성한 주문 ID + orderName: product.project.title, + successUrl: `${window.location.origin}/payment/success`, + failUrl: `${window.location.origin}/payment/fail`, + customerEmail: user.email, + customerName: user.nickname, +}); +// 위 코드 실행 시 토스 결제창으로 리다이렉트됨 +``` + +### 3-4. Vite 환경변수 추가 (`platform/frontend/.env`) + +```env +VITE_TOSS_CLIENT_KEY=live_ck_YOUR_LIVE_KEY +``` + +### 3-5. 결제 성공 페이지 추가 (`pages/PaymentSuccess.jsx`) + +```javascript +// platform/frontend/src/pages/PaymentSuccess.jsx +import { useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import api from '../api/client'; + +export default function PaymentSuccess() { + const [params] = useSearchParams(); + const navigate = useNavigate(); + + useEffect(() => { + const paymentKey = params.get('paymentKey'); + const orderId = params.get('orderId'); + const amount = params.get('amount'); + + // 서버에 결제 승인 요청 + api.post('/payments/toss/confirm', { paymentKey, orderId, amount }) + .then(r => navigate(`/flash/${r.data.flashToken}`)) + .catch(() => navigate('/payment/fail')); + }, []); + + return
; +} +``` + +### 3-6. 백엔드 — 토스 결제 승인 라우트 추가 (`routes/payments.js`) + +```javascript +// platform/backend/src/routes/payments.js +const router = require('express').Router(); +const prisma = require('../config/db'); +const { requireAuth } = require('../middleware/auth'); +const { v4: uuidv4 } = require('uuid'); + +// POST /api/payments/toss/confirm +router.post('/toss/confirm', requireAuth, async (req, res) => { + const { paymentKey, orderId, amount } = req.body; + + // 1. DB에서 주문 확인 + const order = await prisma.order.findFirst({ + where: { tossOrderId: orderId, buyerId: req.user.id, status: 'pending' }, + }); + if (!order || order.amount !== parseInt(amount)) { + return res.status(400).json({ error: '주문 정보가 일치하지 않습니다' }); + } + + // 2. 토스 서버에 결제 승인 요청 + const tossRes = await fetch('https://api.tosspayments.com/v1/payments/confirm', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // 시크릿 키를 Base64로 인코딩 + Authorization: `Basic ${Buffer.from(process.env.TOSS_SECRET_KEY + ':').toString('base64')}`, + }, + body: JSON.stringify({ paymentKey, orderId, amount }), + }); + + if (!tossRes.ok) { + const err = await tossRes.json(); + return res.status(400).json({ error: err.message }); + } + + // 3. 주문 상태 업데이트 + FlashToken 발급 + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const [, flashToken] = await prisma.$transaction([ + prisma.order.update({ + where: { id: order.id }, + data: { status: 'paid', paidAt: new Date(), paymentKey }, + }), + prisma.flashToken.create({ + data: { orderId: order.id, expiresAt }, + }), + prisma.product.update({ + where: { id: order.productId }, + data: { totalSales: { increment: 1 } }, + }), + ]); + + res.json({ flashToken: flashToken.token }); +}); + +// POST /api/payments/toss/webhook — 토스 웹훅 (결제 상태 변경 알림) +router.post('/toss/webhook', async (req, res) => { + // 웹훅 서명 검증 (토스 대시보드에서 시크릿 설정) + const signature = req.headers['toss-payments-signature']; + // TODO: HMAC-SHA256 서명 검증 구현 + + const { eventType, data } = req.body; + if (eventType === 'PAYMENT_STATUS_CHANGED' && data.status === 'CANCELED') { + await prisma.order.updateMany({ + where: { paymentKey: data.paymentKey }, + data: { status: 'cancelled' }, + }); + } + res.json({ success: true }); +}); + +module.exports = router; +``` + +### 3-7. `index.js`에 payments 라우트 추가 + +```javascript +// platform/backend/src/index.js 에 추가 +app.use('/api/payments', require('./routes/payments')); +``` + +--- + +## 4단계 — 환불 처리 (토스 API) + +현재 모의 환불은 DB만 변경합니다. 실결제 환불 시: + +```javascript +// routes/orders.js refund 엔드포인트에 추가 +const tossRefundRes = await fetch( + `https://api.tosspayments.com/v1/payments/${order.paymentKey}/cancel`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${Buffer.from(process.env.TOSS_SECRET_KEY + ':').toString('base64')}`, + }, + body: JSON.stringify({ cancelReason: req.body.reason || '사용자 요청' }), + } +); +if (!tossRefundRes.ok) throw new Error('토스 환불 API 실패'); +``` + +--- + +## 5단계 — 토스 대시보드 설정 + +실키 발급 후 [토스페이먼츠 대시보드](https://app.tosspayments.com)에서: + +| 설정 항목 | 값 | +|----------|-----| +| 결제 성공 URL | `https://your-domain.com/payment/success` | +| 결제 실패 URL | `https://your-domain.com/payment/fail` | +| 웹훅 URL | `https://your-domain.com/api/payments/toss/webhook` | +| 허용 도메인 | `your-domain.com` | + +--- + +## 테스트 → 운영 전환 요약 + +| 항목 | 테스트 모드 (현재) | 운영 모드 (전환 후) | +|------|-----------------|----------------| +| 결제 처리 | mock-pay (즉시) | 토스 결제창 → confirm API | +| API 키 | 없음 | live_ck_, live_sk_ | +| 실제 청구 | 없음 | 있음 | +| 환불 | DB만 변경 | 토스 cancel API 호출 | +| 코드 변경 | - | ProductDetail.jsx, payments.js 추가, index.js 라우트 등록 | + +--- + +## 해외 결제 (Stripe) — 차후 적용 + +Stripe는 사업자 없이도 개인으로 가입 가능합니다. + +```bash +npm install stripe +``` + +주요 차이점: +- Stripe는 USD 기준 → 원화 환율 처리 필요 +- Payment Intent 방식 사용 +- 수수료: 약 2.9% + $0.30/건 + +별도 가이드 예정. diff --git a/platform/backend/Dockerfile b/platform/backend/Dockerfile index 4d8e8c7..48deed1 100644 --- a/platform/backend/Dockerfile +++ b/platform/backend/Dockerfile @@ -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"] diff --git a/platform/backend/prisma/schema.prisma b/platform/backend/prisma/schema.prisma index d2ee1d7..eb6ed05 100644 --- a/platform/backend/prisma/schema.prisma +++ b/platform/backend/prisma/schema.prisma @@ -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()) } diff --git a/platform/backend/src/config/minio.js b/platform/backend/src/config/minio.js index a88b0f6..f624bc5 100644 --- a/platform/backend/src/config/minio.js +++ b/platform/backend/src/config/minio.js @@ -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 }; diff --git a/platform/backend/src/index.js b/platform/backend/src/index.js index c361800..05a58c1 100644 --- a/platform/backend/src/index.js +++ b/platform/backend/src/index.js @@ -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')); // 헬스 체크 diff --git a/platform/backend/src/routes/flash.js b/platform/backend/src/routes/flash.js new file mode 100644 index 0000000..df37437 --- /dev/null +++ b/platform/backend/src/routes/flash.js @@ -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; diff --git a/platform/backend/src/routes/orders.js b/platform/backend/src/routes/orders.js new file mode 100644 index 0000000..187b7fa --- /dev/null +++ b/platform/backend/src/routes/orders.js @@ -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; diff --git a/platform/backend/src/routes/projects.js b/platform/backend/src/routes/projects.js index 1feec7e..ac7b6e5 100644 --- a/platform/backend/src/routes/projects.js +++ b/platform/backend/src/routes/projects.js @@ -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, }, }); diff --git a/platform/frontend/index.html b/platform/frontend/index.html index f0a9d7b..38c16d6 100644 --- a/platform/frontend/index.html +++ b/platform/frontend/index.html @@ -5,6 +5,7 @@ ESP32 DIY 플랫폼 +
diff --git a/platform/frontend/src/App.jsx b/platform/frontend/src/App.jsx index 407e7b1..408dfec 100644 --- a/platform/frontend/src/App.jsx +++ b/platform/frontend/src/App.jsx @@ -3,6 +3,7 @@ import { AuthProvider, useAuth } from './hooks/useAuth'; import Navbar from './components/Navbar'; import Home from './pages/Home'; +import Flash from './pages/Flash'; import Projects from './pages/Projects'; import ProjectDetail from './pages/ProjectDetail'; import Shop from './pages/Shop'; @@ -51,6 +52,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/platform/frontend/src/pages/Dashboard/MyOrders.jsx b/platform/frontend/src/pages/Dashboard/MyOrders.jsx index 13f9d4f..6919660 100644 --- a/platform/frontend/src/pages/Dashboard/MyOrders.jsx +++ b/platform/frontend/src/pages/Dashboard/MyOrders.jsx @@ -1,10 +1,103 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import api from '../../api/client'; + +const STATUS_LABEL = { + pending: { label: '결제 대기', color: 'var(--warn)' }, + paid: { label: '결제 완료', color: 'var(--success)' }, + refunded: { label: '환불됨', color: 'var(--text2)' }, + cancelled:{ label: '취소됨', color: 'var(--text2)' }, +}; + export default function MyOrders() { + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + api.get('/orders/me') + .then(r => setOrders(r.data)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + async function handleRefund(orderId) { + if (!confirm('환불 요청하시겠습니까?\n플래시 완료 후에는 환불이 불가능합니다.')) return; + try { + await api.post(`/orders/${orderId}/refund`, { reason: '사용자 요청' }); + setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: 'refunded' } : o)); + } catch (err) { + alert(err.response?.data?.error || '환불 처리 실패'); + } + } + + if (loading) return
; + return (

구매 내역

-
-

결제 기능은 2단계에서 구현됩니다.

-
+ + {orders.length === 0 ? ( +
+

구매 내역이 없습니다.

+ 상점 둘러보기 +
+ ) : ( +
+ {orders.map(order => { + const st = STATUS_LABEL[order.status] || { label: order.status, color: 'var(--text2)' }; + const thumb = order.product?.project?.files?.[0]; + const ft = order.flashToken; + + return ( +
+ {/* 썸네일 */} + {thumb + ? + :
📦
+ } + + {/* 정보 */} +
+
+ {order.product?.project?.title || '상품'} +
+
+ ₩{order.amount.toLocaleString()} · {new Date(order.orderedAt).toLocaleDateString('ko-KR')} + {' · '}{st.label} +
+ {ft && ( +
+ {ft.isUsed + ? ✅ 플래시 완료 + : new Date() > new Date(ft.expiresAt) + ? ⏰ 토큰 만료 + : + 🔑 토큰 유효 · 만료: {new Date(ft.expiresAt).toLocaleDateString('ko-KR')} + + } +
+ )} +
+ + {/* 액션 버튼 */} +
+ {ft && !ft.isUsed && order.status === 'paid' && new Date() <= new Date(ft.expiresAt) && ( + + ⚡ 플래시 + + )} + {order.status === 'paid' && !ft?.isUsed && ( + + )} +
+
+ ); + })} +
+ )}
); } diff --git a/platform/frontend/src/pages/Dashboard/ProjectNew.jsx b/platform/frontend/src/pages/Dashboard/ProjectNew.jsx index a1aec74..34ba976 100644 --- a/platform/frontend/src/pages/Dashboard/ProjectNew.jsx +++ b/platform/frontend/src/pages/Dashboard/ProjectNew.jsx @@ -5,11 +5,12 @@ import api from '../../api/client'; export default function ProjectNew() { const navigate = useNavigate(); const [form, setForm] = useState({ - title: '', description: '', difficultyLevel: 3, requiredParts: [], + title: '', description: '', difficultyLevel: 3, chipFamily: 'ESP32-S3', requiredParts: [], }); const [partRow, setPartRow] = useState({ name: '', quantity: '', link: '' }); const [files, setFiles] = useState([]); const [fileType, setFileType] = useState('image'); + const [flashOffset, setOffset] = useState('0x0'); const [dragging, setDragging] = useState(false); const [error, setError] = useState(''); const [step, setStep] = useState(1); // 1:기본정보, 2:파일, 3:완료 @@ -35,6 +36,7 @@ export default function ProjectNew() { title: form.title, description: form.description, difficultyLevel: form.difficultyLevel, + chipFamily: form.chipFamily, requiredParts: form.requiredParts, }); setProjectId(data.id); @@ -53,6 +55,7 @@ export default function ProjectNew() { try { const fd = new FormData(); fd.append('fileType', fileType); + if (fileType === 'firmware') fd.append('flashOffset', flashOffset); files.forEach(f => fd.append('files', f)); await api.post(`/projects/${projectId}/files`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, @@ -114,11 +117,24 @@ export default function ProjectNew() { onChange={e => setForm(f => ({ ...f, description: e.target.value }))} placeholder="프로젝트 목적, 기능, 특징을 설명해주세요" rows={6} />
-
- - +
+
+ + +
+
+ + +
{/* 필요 부품 */} @@ -150,15 +166,29 @@ export default function ProjectNew() { {step === 2 && (

파일 업로드

-
- - +
+
+ + +
+ {fileType === 'firmware' && ( +
+ + +
+ )}
{ e.preventDefault(); setDragging(true); }} diff --git a/platform/frontend/src/pages/Flash.jsx b/platform/frontend/src/pages/Flash.jsx new file mode 100644 index 0000000..88c0df5 --- /dev/null +++ b/platform/frontend/src/pages/Flash.jsx @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import api from '../api/client'; + +const CHIP_LABELS = { + 'ESP32-S3': 'ESP32-S3', 'ESP32-S2': 'ESP32-S2', 'ESP32-C3': 'ESP32-C3', + 'ESP32-C6': 'ESP32-C6', 'ESP32-H2': 'ESP32-H2', 'ESP32': 'ESP32', +}; + +export default function Flash() { + const { token } = useParams(); + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [flashDone, setFlashDone] = useState(false); + const installRef = useRef(null); + + const manifestUrl = `${window.location.origin}/api/flash/${token}/manifest`; + + useEffect(() => { + api.get(`/flash/${token}`) + .then(r => setInfo(r.data)) + .catch(() => setInfo(null)) + .finally(() => setLoading(false)); + }, [token]); + + // esp-web-tools 이벤트 — 플래시 완료/실패 시 서버에 기록 + useEffect(() => { + const btn = installRef.current; + if (!btn) return; + + function onSuccess(e) { + const mac = e.detail?.device?.macAddress || 'unknown'; + api.post(`/flash/${token}/consume`, { + mac, chipFamily: info?.chipFamily, success: true, + }).catch(() => {}); + setFlashDone(true); + } + function onFail(e) { + api.post(`/flash/${token}/consume`, { + mac: 'unknown', chipFamily: info?.chipFamily, + success: false, errorMessage: e.detail?.message, + }).catch(() => {}); + } + + btn.addEventListener('state-changed', (e) => { + if (e.detail?.state === 'finished') onSuccess(e); + if (e.detail?.state === 'error') onFail(e); + }); + }, [info, token]); + + if (loading) return
; + + // 유효하지 않은 토큰 + if (!info) { + return ( +
+
+
+

유효하지 않은 토큰

+

토큰을 찾을 수 없습니다.

+ + 구매 내역으로 + +
+
+ ); + } + + // 이미 사용된 토큰 + if (info.isUsed) { + return ( +
+
+
+

이미 플래시됨

+

+ 이 토큰은 {info.usedAt ? new Date(info.usedAt).toLocaleString('ko-KR') : ''}에 사용되었습니다. +

+

1회용 토큰은 재사용할 수 없습니다.

+ + 구매 내역으로 + +
+
+ ); + } + + // 만료된 토큰 + if (info.expired) { + return ( +
+
+
+

만료된 토큰

+

토큰 유효기간이 지났습니다.

+

고객센터에 문의해주세요.

+ + 구매 내역으로 + +
+
+ ); + } + + // 펌웨어 없음 + if (!info.hasFirmware) { + return ( +
+
+
⚠️
+

펌웨어 파일 없음

+

판매자가 아직 펌웨어를 업로드하지 않았습니다.

+

판매자에게 문의하거나 환불을 요청하세요.

+
+
+ ); + } + + return ( +
+
+
+
+
+

{info.productName}

+ + {CHIP_LABELS[info.chipFamily] || info.chipFamily} · 만료: {new Date(info.expiresAt).toLocaleDateString('ko-KR')} + +
+
+ + {flashDone ? ( +
+
🎉
+ 플래시 완료! +

펌웨어가 성공적으로 ESP32에 기록되었습니다.

+
+ 구매 내역 +
+
+ ) : ( + <> +
+ 플래시 전 확인사항 +
    +
  • Chrome 또는 Edge 브라우저를 사용하고 있나요?
  • +
  • ESP32를 USB 케이블로 PC에 연결했나요?
  • +
  • 이 토큰은 1회만 사용 가능합니다
  • +
+
+ +
+ + + + Chrome 또는 Edge 브라우저가 필요합니다 + + +

+ 버튼 클릭 후 팝업에서 ESP32 포트를 선택하세요 +

+
+ +
+
+ 토큰 정보 (고급) + + {token} + +

매니페스트 URL: {manifestUrl}

+
+ + )} +
+
+ ); +} diff --git a/platform/frontend/src/pages/ProductDetail.jsx b/platform/frontend/src/pages/ProductDetail.jsx index b5edd09..bcf2d5c 100644 --- a/platform/frontend/src/pages/ProductDetail.jsx +++ b/platform/frontend/src/pages/ProductDetail.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate, Link } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; import api from '../api/client'; @@ -13,6 +13,8 @@ export default function ProductDetail() { const { user } = useAuth(); const [product, setProduct] = useState(null); const [loading, setLoading] = useState(true); + const [buying, setBuying] = useState(false); + const [buyError, setBuyError] = useState(''); const [imgIdx, setImgIdx] = useState(0); useEffect(() => { @@ -22,6 +24,46 @@ export default function ProductDetail() { .finally(() => setLoading(false)); }, [id]); + async function handleBuy() { + if (!user) { + navigate('/auth/login', { state: { from: `/shop/${id}` } }); + return; + } + setBuyError(''); + setBuying(true); + try { + // 1. 주문 생성 + let orderId, flashToken; + try { + const res = await api.post('/orders', { productId: product.id }); + orderId = res.data.orderId; + // 이미 구매한 경우 바로 Flash 페이지로 + if (res.data.flashToken) { + navigate(`/flash/${res.data.flashToken}`); + return; + } + } catch (err) { + // 이미 결제 완료된 주문 + if (err.response?.data?.flashToken) { + navigate(`/flash/${err.response.data.flashToken}`); + return; + } + throw err; + } + + // 2. 모의 결제 처리 + const payRes = await api.post(`/orders/${orderId}/mock-pay`); + flashToken = payRes.data.flashToken; + + // 3. Flash 페이지로 이동 + navigate(`/flash/${flashToken}`); + } catch (err) { + setBuyError(err.response?.data?.error || '구매 처리 중 오류가 발생했습니다'); + } finally { + setBuying(false); + } + } + if (loading) return
; if (!product) return null; @@ -31,7 +73,7 @@ export default function ProductDetail() { return (
-
+
{/* 왼쪽 */}
{images.length > 0 && ( @@ -52,7 +94,7 @@ export default function ProductDetail() {

{product.project.title}

- by {product.project.user.nickname} + by {product.project.user.nickname} · 칩: {product.project.chipFamily}
{avgRating && (
@@ -98,8 +140,8 @@ export default function ProductDetail() {
{/* 오른쪽 — 구매 패널 */} -
-
+
+
₩{product.price.toLocaleString()}
@@ -109,22 +151,34 @@ export default function ProductDetail() { {product.isOnSale ? ( <> - {user ? ( - - ) : ( - - )} + {buyError &&
{buyError}
} + + +
+ + {/* 테스트 모드 안내 */} +
+
+ 🧪 테스트 모드 +
+
+ 현재 모의 결제로 동작합니다. 실제 요금이 청구되지 않습니다. +
+
+
    -
  • 구매 즉시 플래시 토큰 발급
  • -
  • USB 연결 후 브라우저에서 플래시
  • +
  • 구매 즉시 1회용 플래시 토큰 발급
  • +
  • USB 연결 후 브라우저에서 직접 플래시
  • 플래시 완료 후 환불 불가
  • +
  • 토큰 유효기간: 30일
) : (