feat: add ESP32 DIY platform Phase 1 (marketplace scaffold)
- Docker Compose with Postgres, Redis, MinIO, backend, frontend (port 3200/3201) - Prisma schema: User, Project, ProjectFile, Product, Order, FlashToken, Review, AuditLog - Backend: JWT auth, project CRUD + file upload (MinIO + sharp WebP), admin approval flow - Frontend: React + Vite SPA with auth, project/shop browse, seller dashboard, admin panel - Admin: pending approval queue, user management, audit log viewer, stats dashboard - Audit logging middleware for legal compliance - Admin init script: createAdmin.js - Full design document in PLATFORM_DESIGN.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
819
PLATFORM_DESIGN.md
Normal file
819
PLATFORM_DESIGN.md
Normal file
@@ -0,0 +1,819 @@
|
|||||||
|
# ESP32 DIY 플랫폼 — 전체 설계 문서
|
||||||
|
|
||||||
|
> webflash(포트 3100)와 연동되는 ESP32 프로젝트 마켓플레이스
|
||||||
|
> 포트: **3200** (프론트엔드 Nginx) / **3201** (백엔드 API)
|
||||||
|
> 작성일: 2026-05-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 프로젝트 개요
|
||||||
|
|
||||||
|
| 구분 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 목적 | ESP32 DIY 프로젝트 공유·판매 플랫폼 |
|
||||||
|
| 사용자 역할 | `admin` / `seller` (프로젝트 등록자) / `buyer` (구매자) |
|
||||||
|
| 수익 구조 | 판매가의 일정 % 수수료 (프로젝트별 admin 설정) |
|
||||||
|
| 결제 | 토스페이먼츠 (1차), Stripe (2차) |
|
||||||
|
| 플래시 연동 | 구매 후 1회용 토큰 → webflash에서 토큰 검증 후 플래시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 시스템 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
외부 접근 (HTTPS — 사용자 직접 설정)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Docker Network │
|
||||||
|
│ │
|
||||||
|
│ :3200 platform-frontend (Nginx + React SPA) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ /api/* /auth/* │
|
||||||
|
│ :3201 platform-backend (Node.js + Express) │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────┼──────────────┐ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ postgres redis minio (:9000) │
|
||||||
|
│ (DB) (세션·캐시) (파일 스토리지) │
|
||||||
|
│ │
|
||||||
|
│ :3100 webflash (기존, 별도 네트워크) │
|
||||||
|
│ platform-backend → webflash API (토큰 검증용) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 디렉터리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
webflash/ ← 기존 webflash
|
||||||
|
platform/
|
||||||
|
├── backend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── prisma/
|
||||||
|
│ │ └── schema.prisma ← DB 스키마
|
||||||
|
│ └── src/
|
||||||
|
│ ├── index.js ← 진입점
|
||||||
|
│ ├── config/
|
||||||
|
│ │ ├── db.js ← Prisma 클라이언트
|
||||||
|
│ │ ├── redis.js
|
||||||
|
│ │ └── minio.js ← S3 클라이언트
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── auth.js ← JWT 검증
|
||||||
|
│ │ ├── roles.js ← admin/seller/buyer 가드
|
||||||
|
│ │ ├── audit.js ← 모든 요청 자동 로깅
|
||||||
|
│ │ └── rateLimit.js ← Redis 기반
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── auth.js
|
||||||
|
│ │ ├── users.js
|
||||||
|
│ │ ├── projects.js
|
||||||
|
│ │ ├── products.js
|
||||||
|
│ │ ├── orders.js
|
||||||
|
│ │ ├── payments.js ← 토스페이먼츠
|
||||||
|
│ │ ├── reviews.js
|
||||||
|
│ │ ├── flash.js ← 토큰 발급·검증
|
||||||
|
│ │ └── admin.js
|
||||||
|
│ └── services/
|
||||||
|
│ ├── media.js ← sharp + ffmpeg 압축
|
||||||
|
│ ├── toss.js ← 토스 API 래퍼
|
||||||
|
│ └── storage.js ← MinIO 업·다운로드
|
||||||
|
├── frontend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── nginx.conf
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.jsx
|
||||||
|
│ ├── App.jsx ← React Router 루트
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── Home.jsx
|
||||||
|
│ │ ├── Projects.jsx ← 탐색
|
||||||
|
│ │ ├── ProjectDetail.jsx
|
||||||
|
│ │ ├── Shop.jsx ← 상품 목록
|
||||||
|
│ │ ├── ProductDetail.jsx ← 구매·리뷰
|
||||||
|
│ │ ├── Auth/
|
||||||
|
│ │ │ ├── Login.jsx
|
||||||
|
│ │ │ └── Register.jsx
|
||||||
|
│ │ ├── Dashboard/ ← 판매자
|
||||||
|
│ │ │ ├── Index.jsx
|
||||||
|
│ │ │ ├── ProjectNew.jsx
|
||||||
|
│ │ │ ├── ProjectEdit.jsx
|
||||||
|
│ │ │ ├── Orders.jsx
|
||||||
|
│ │ │ └── Sales.jsx
|
||||||
|
│ │ ├── Flash.jsx ← 구매 후 플래시 페이지
|
||||||
|
│ │ └── Admin/
|
||||||
|
│ │ ├── Index.jsx
|
||||||
|
│ │ ├── PendingProjects.jsx
|
||||||
|
│ │ ├── Users.jsx
|
||||||
|
│ │ ├── Orders.jsx
|
||||||
|
│ │ └── Logs.jsx
|
||||||
|
│ ├── components/ ← 공통 컴포넌트
|
||||||
|
│ └── hooks/ ← useAuth, useCart 등
|
||||||
|
└── docker-compose.yml ← platform 전용
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# platform/docker-compose.yml
|
||||||
|
services:
|
||||||
|
platform-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- platform-db-data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: platform
|
||||||
|
POSTGRES_USER: platform
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U platform"]
|
||||||
|
interval: 10s
|
||||||
|
|
||||||
|
platform-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- platform-redis-data:/data
|
||||||
|
|
||||||
|
platform-minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- platform-storage:/data
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
# 9000: S3 API (backend 내부용), 9001: 관리 콘솔
|
||||||
|
|
||||||
|
platform-backend:
|
||||||
|
build: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3201:3201"
|
||||||
|
depends_on:
|
||||||
|
platform-db:
|
||||||
|
condition: service_healthy
|
||||||
|
platform-redis:
|
||||||
|
condition: service_started
|
||||||
|
platform-minio:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
PORT: 3201
|
||||||
|
DATABASE_URL: postgresql://platform:${DB_PASSWORD}@platform-db:5432/platform
|
||||||
|
REDIS_URL: redis://platform-redis:6379
|
||||||
|
MINIO_ENDPOINT: platform-minio
|
||||||
|
MINIO_PORT: 9000
|
||||||
|
MINIO_ACCESS_KEY: ${MINIO_USER}
|
||||||
|
MINIO_SECRET_KEY: ${MINIO_PASSWORD}
|
||||||
|
MINIO_BUCKET: platform
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_EXPIRES_IN: 7d
|
||||||
|
TOSS_CLIENT_KEY: ${TOSS_CLIENT_KEY}
|
||||||
|
TOSS_SECRET_KEY: ${TOSS_SECRET_KEY}
|
||||||
|
TOSS_SUCCESS_URL: ${BASE_URL}/payment/success
|
||||||
|
TOSS_FAIL_URL: ${BASE_URL}/payment/fail
|
||||||
|
WEBFLASH_API_URL: http://host.docker.internal:3000 # webflash backend
|
||||||
|
ALLOWED_ORIGIN: ${BASE_URL}
|
||||||
|
|
||||||
|
platform-frontend:
|
||||||
|
build: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3200:80"
|
||||||
|
depends_on:
|
||||||
|
- platform-backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
platform-db-data:
|
||||||
|
platform-redis-data:
|
||||||
|
platform-storage:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 데이터베이스 스키마 (Prisma)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
// platform/backend/prisma/schema.prisma
|
||||||
|
|
||||||
|
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) // 1~5
|
||||||
|
requiredParts Json? // [{name, link, quantity}]
|
||||||
|
status ProjectStatus @default(draft)
|
||||||
|
adminNote String? // 반려 사유 등
|
||||||
|
commissionRate Float @default(0.1) // 10%
|
||||||
|
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 // image | video | stl | wiring | firmware
|
||||||
|
url String // MinIO presigned or public URL
|
||||||
|
thumbnailUrl String? // 영상/이미지 썸네일
|
||||||
|
fileSize Int
|
||||||
|
mimeType String
|
||||||
|
displayOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
projectId String @unique
|
||||||
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
|
price Int // 원 단위 (KRW)
|
||||||
|
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? // 토스 paymentKey / Stripe PaymentIntent ID
|
||||||
|
orderId String? @unique // 토스 orderId (자체 생성)
|
||||||
|
status OrderStatus @default(pending)
|
||||||
|
buyerInfo Json // {name, phone, email} — 법적 보존
|
||||||
|
deviceInfo Json? // {userAgent, ip}
|
||||||
|
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 // 발급 후 30일
|
||||||
|
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 // 1~5
|
||||||
|
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 // image | video
|
||||||
|
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 // LOGIN | REGISTER | PROJECT_SUBMIT | ORDER_CREATE | PAYMENT_CONFIRM | FLASH | REVIEW_POST | ADMIN_APPROVE | ...
|
||||||
|
targetType String? // Project | Order | User | Product | Review
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. REST API 목록
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
| 메서드 | 경로 | 설명 | 권한 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| POST | `/api/auth/register` | 회원가입 | Public |
|
||||||
|
| POST | `/api/auth/login` | 로그인 → JWT 반환 | Public |
|
||||||
|
| POST | `/api/auth/logout` | 로그아웃 (Redis 블랙리스트) | User |
|
||||||
|
| GET | `/api/auth/me` | 내 프로필 | User |
|
||||||
|
| POST | `/api/auth/refresh` | 액세스 토큰 갱신 | User |
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
| 메서드 | 경로 | 설명 | 권한 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/projects` | 승인된 프로젝트 목록 (페이지네이션) | Public |
|
||||||
|
| GET | `/api/projects/:id` | 프로젝트 상세 | Public |
|
||||||
|
| POST | `/api/projects` | 새 프로젝트 생성 | User |
|
||||||
|
| PUT | `/api/projects/:id` | 수정 (draft 상태만) | Owner |
|
||||||
|
| DELETE | `/api/projects/:id` | 삭제 | Owner/Admin |
|
||||||
|
| POST | `/api/projects/:id/submit` | 관리자 검토 요청 | Owner |
|
||||||
|
| POST | `/api/projects/:id/files` | 파일 업로드 (multipart) | Owner |
|
||||||
|
| DELETE | `/api/projects/:id/files/:fileId` | 파일 삭제 | Owner |
|
||||||
|
|
||||||
|
### Products & Shop
|
||||||
|
| 메서드 | 경로 | 설명 | 권한 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/products` | 판매 중인 상품 목록 | Public |
|
||||||
|
| GET | `/api/products/:id` | 상품 상세 + 리뷰 | Public |
|
||||||
|
| PUT | `/api/products/:id/toggle` | 판매 일시중지/재개 | Owner/Admin |
|
||||||
|
|
||||||
|
### Orders & Payment (토스페이먼츠)
|
||||||
|
| 메서드 | 경로 | 설명 | 권한 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| POST | `/api/orders` | 주문 생성 (결제 전) | User |
|
||||||
|
| GET | `/api/orders/:id` | 주문 상세 | Owner/Admin |
|
||||||
|
| GET | `/api/orders/me` | 내 주문 목록 | User |
|
||||||
|
| POST | `/api/payments/toss/confirm` | 토스 결제 승인 (서버→토스) | User |
|
||||||
|
| POST | `/api/payments/toss/webhook` | 토스 웹훅 수신 | Internal |
|
||||||
|
| POST | `/api/orders/:id/refund` | 환불 요청 | User/Admin |
|
||||||
|
|
||||||
|
### Flash Token
|
||||||
|
| 메서드 | 경로 | 설명 | 권한 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/flash/:token` | 토큰 정보 조회 (webflash가 호출) | Token |
|
||||||
|
| POST | `/api/flash/:token/consume` | 플래시 완료 기록 (webflash가 호출) | Token |
|
||||||
|
|
||||||
|
### Reviews
|
||||||
|
| 메서드 | 경로 | 설명 | 권한 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/reviews?productId=` | 상품 리뷰 목록 | Public |
|
||||||
|
| POST | `/api/reviews` | 리뷰 작성 (구매자만) | Buyer |
|
||||||
|
| PUT | `/api/reviews/:id` | 수정 | Owner |
|
||||||
|
| DELETE | `/api/reviews/:id` | 삭제 | Owner/Admin |
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
| 메서드 | 경로 | 설명 | 권한 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/api/admin/projects/pending` | 승인 대기 목록 | Admin |
|
||||||
|
| POST | `/api/admin/projects/:id/approve` | 승인 (commissionRate 설정) | Admin |
|
||||||
|
| POST | `/api/admin/projects/:id/reject` | 반려 (사유 포함) | Admin |
|
||||||
|
| GET | `/api/admin/users` | 사용자 목록 | Admin |
|
||||||
|
| PUT | `/api/admin/users/:id/toggle` | 계정 활성/비활성 | Admin |
|
||||||
|
| GET | `/api/admin/logs` | 감사 로그 조회 (필터·페이지네이션) | Admin |
|
||||||
|
| GET | `/api/admin/flash-logs` | 플래시 로그 | Admin |
|
||||||
|
| GET | `/api/admin/stats` | 매출·가입·플래시 통계 | Admin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 주요 사용자 흐름
|
||||||
|
|
||||||
|
### 7-1. 프로젝트 등록 → 판매 승인 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
판매자 서버 관리자
|
||||||
|
│ │ │
|
||||||
|
│── POST /projects ────────────>│ draft 상태 생성 │
|
||||||
|
│── POST /projects/:id/files ──>│ MinIO 업로드 + 압축 │
|
||||||
|
│── POST /projects/:id/submit ─>│ status = pending │
|
||||||
|
│ │── 관리자에게 알림 ─────────>│
|
||||||
|
│ │ │── GET /admin/projects/pending
|
||||||
|
│ │ │── POST /admin/projects/:id/approve
|
||||||
|
│ │<─── commissionRate 설정 ───│
|
||||||
|
│ │ status = approved │
|
||||||
|
│ │ Product 자동 생성 │
|
||||||
|
│<── 이메일 알림 ───────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7-2. 구매 → 플래시 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
구매자 platform-backend webflash-backend
|
||||||
|
│ │ │
|
||||||
|
│── POST /orders ──────────────>│ Order(pending) 생성 │
|
||||||
|
│ (토스 결제창 진행) │ │
|
||||||
|
│── POST /payments/toss/confirm>│ 토스 API 승인 요청 │
|
||||||
|
│ │── 토스 서버 확인 │
|
||||||
|
│ │ Order status = paid │
|
||||||
|
│ │ FlashToken 생성 (UUID, 30일)│
|
||||||
|
│<── { flashToken, orderId } ───│ │
|
||||||
|
│ │ │
|
||||||
|
│── GET /flash/[token] ────────────────────────────────────>│
|
||||||
|
│ │ │── token 검증 API 호출
|
||||||
|
│ │<── validate ──────────────│
|
||||||
|
│ │── firmware manifest 반환 ─>│
|
||||||
|
│ │ │── manifest 서빙
|
||||||
|
│ (브라우저 → USB → ESP32 플래시)│ │
|
||||||
|
│── POST /flash/[token]/consume ─────────────────────────────>│
|
||||||
|
│ │<── {mac, chipFamily} ──────│
|
||||||
|
│ │ FlashToken.isUsed = true │
|
||||||
|
│ │ FlashLog 저장 │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7-3. 리뷰 작성 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
구매자 (플래시 완료 후)
|
||||||
|
│
|
||||||
|
│── POST /reviews
|
||||||
|
│ body: { orderId, rating, title, content }
|
||||||
|
│ files: images[] + videos[]
|
||||||
|
│
|
||||||
|
서버:
|
||||||
|
├── 주문 소유자 확인
|
||||||
|
├── FlashToken.isUsed = true 확인 (구매+플래시 완료 검증)
|
||||||
|
├── 이미지: sharp → WebP 변환, 1920px 리사이즈, 80% 품질
|
||||||
|
├── 영상: ffmpeg → H.264/720p, 2Mbps, 최대 3분
|
||||||
|
└── ReviewMedia 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 파일 처리 (미디어 압축)
|
||||||
|
|
||||||
|
### 이미지 (sharp 라이브러리)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 업로드 즉시 처리
|
||||||
|
const processed = await sharp(buffer)
|
||||||
|
.resize(1920, 1080, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.webp({ quality: 82 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// 썸네일
|
||||||
|
const thumb = await sharp(buffer)
|
||||||
|
.resize(400, 300, { fit: 'cover' })
|
||||||
|
.webp({ quality: 70 })
|
||||||
|
.toBuffer();
|
||||||
|
```
|
||||||
|
|
||||||
|
| 원본 | 처리 후 | 제한 |
|
||||||
|
|------|---------|------|
|
||||||
|
| JPEG/PNG/WEBP | WebP 1920px이하 82% | 업로드 최대 20MB |
|
||||||
|
| 썸네일 | WebP 400×300 | - |
|
||||||
|
|
||||||
|
### 영상 (fluent-ffmpeg)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 업로드 후 비동기 처리 (백그라운드 작업)
|
||||||
|
ffmpeg(inputPath)
|
||||||
|
.videoCodec('libx264')
|
||||||
|
.audioCodec('aac')
|
||||||
|
.videoBitrate('2000k')
|
||||||
|
.audioBitrate('128k')
|
||||||
|
.size('?x720') // 720p (세로 기준)
|
||||||
|
.duration(180) // 최대 3분
|
||||||
|
.outputOptions(['-preset fast', '-crf 23'])
|
||||||
|
.output(outputPath)
|
||||||
|
.run();
|
||||||
|
```
|
||||||
|
|
||||||
|
| 원본 | 처리 후 | 제한 |
|
||||||
|
|------|---------|------|
|
||||||
|
| MP4/MOV/AVI | H.264/720p/2Mbps | 업로드 최대 500MB, 처리 후 ~100MB 이하 |
|
||||||
|
| 썸네일 | 첫 프레임 WebP | - |
|
||||||
|
|
||||||
|
### 프로젝트 파일 허용 타입
|
||||||
|
|
||||||
|
| 필드 | 허용 확장자 | 최대 크기 |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| 이미지 | jpg, jpeg, png, webp | 20 MB |
|
||||||
|
| 영상 | mp4, mov, avi, webm | 500 MB |
|
||||||
|
| 배선도 | jpg, png, pdf, svg | 20 MB |
|
||||||
|
| STL | stl | 50 MB |
|
||||||
|
| 펌웨어 | bin | 64 MB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 결제 흐름 — 토스페이먼츠
|
||||||
|
|
||||||
|
```
|
||||||
|
프론트엔드 백엔드 토스페이먼츠
|
||||||
|
│ │ │
|
||||||
|
│── POST /api/orders ──────────>│ Order 생성 (pending) │
|
||||||
|
│<── { orderId, amount } ───────│ │
|
||||||
|
│ │ │
|
||||||
|
│ (TossPayments.js SDK 결제창) │ │
|
||||||
|
│──────────────────────────────────────────────────────────>│
|
||||||
|
│<── successUrl?paymentKey=XXX&orderId=YYY&amount=ZZZ ──────│
|
||||||
|
│ │ │
|
||||||
|
│── POST /api/payments/toss/confirm │
|
||||||
|
│ { paymentKey, orderId, amount } ──────────────────────>│
|
||||||
|
│ │── POST /v1/payments/confirm>│
|
||||||
|
│ │<── 결제 정보 응답 ─────────│
|
||||||
|
│ │ Order.status = paid │
|
||||||
|
│ │ FlashToken 생성 │
|
||||||
|
│<── { flashToken } ────────────│ │
|
||||||
|
│ │ │
|
||||||
|
│ (환불 시) │ │
|
||||||
|
│── POST /api/orders/:id/refund>│── POST /v1/payments/{key}/cancel
|
||||||
|
│ │<── 환불 완료 │
|
||||||
|
│ │ Order.status = refunded │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 환불 정책 (서버 로직)
|
||||||
|
- 플래시 미완료: 전액 환불 가능
|
||||||
|
- 플래시 완료(FlashToken.isUsed=true): 환불 불가 (정책 명시)
|
||||||
|
- 관리자: 특수 케이스 강제 환불 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. webflash 연동 상세
|
||||||
|
|
||||||
|
webflash `backend/server.js`에 토큰 검증 엔드포인트 추가:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// webflash backend에 추가할 코드
|
||||||
|
app.get('/api/firmware/token/:token/manifest', async (req, res) => {
|
||||||
|
// platform backend에 토큰 검증 요청
|
||||||
|
const resp = await fetch(
|
||||||
|
`${PLATFORM_API}/api/flash/${req.params.token}`
|
||||||
|
);
|
||||||
|
if (!resp.ok) return res.status(403).json({ error: '유효하지 않은 토큰' });
|
||||||
|
|
||||||
|
const { firmwareId } = await resp.json();
|
||||||
|
const firmware = loadMeta().find(f => f.id === firmwareId);
|
||||||
|
if (!firmware) return res.status(404).json({ error: '펌웨어 없음' });
|
||||||
|
|
||||||
|
// 기존 manifest 생성 로직 재사용
|
||||||
|
res.json(buildManifest(firmware));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 플래시 완료 콜백
|
||||||
|
app.post('/api/firmware/token/:token/complete', async (req, res) => {
|
||||||
|
const { mac, chipFamily } = req.body;
|
||||||
|
await fetch(`${PLATFORM_API}/api/flash/${req.params.token}/consume`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mac, chipFamily }),
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
플랫폼 플래시 페이지 (`/flash/:token`):
|
||||||
|
```
|
||||||
|
1. 토큰 유효성 표시 (만료일, 사용 여부)
|
||||||
|
2. webflash를 iframe 또는 새 탭으로 열기
|
||||||
|
URL: http://localhost:3100?token=xxx (또는 외부 도메인)
|
||||||
|
3. 플래시 완료 후 리뷰 작성 유도
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 감사 로그 (AuditLog) 전략
|
||||||
|
|
||||||
|
### 자동 로깅 미들웨어
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// middleware/audit.js
|
||||||
|
// 모든 POST/PUT/DELETE에 자동 적용
|
||||||
|
// 민감 필드 마스킹: password, passwordHash, cardNumber
|
||||||
|
const SENSITIVE = ['password', 'passwordHash', 'token', 'paymentKey'];
|
||||||
|
|
||||||
|
function maskBody(body) {
|
||||||
|
const masked = { ...body };
|
||||||
|
SENSITIVE.forEach(k => { if (masked[k]) masked[k] = '***'; });
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 법적 대응에 필요한 보존 데이터
|
||||||
|
|
||||||
|
| 이벤트 | 저장 데이터 |
|
||||||
|
|--------|-----------|
|
||||||
|
| 회원가입 | email, ip, userAgent, timestamp |
|
||||||
|
| 로그인 | userId, ip, userAgent, success/fail, timestamp |
|
||||||
|
| 구매 | orderId, buyerId, productId, amount, ip, buyerInfo JSON |
|
||||||
|
| 결제 확인 | paymentKey, orderId, amount, 토스 응답 전체 |
|
||||||
|
| 환불 | orderId, reason, adminId(강제환불 시), timestamp |
|
||||||
|
| 플래시 | token, mac, chipFamily, ip, firmwareId, success |
|
||||||
|
| 리뷰 | reviewId, orderId, rating, ip |
|
||||||
|
| 관리자 행동 | adminId, action, targetId, timestamp |
|
||||||
|
|
||||||
|
### 보존 정책
|
||||||
|
- AuditLog: **영구 보존** (삭제 API 없음)
|
||||||
|
- FlashLog: **영구 보존**
|
||||||
|
- Order + 결제 정보: **영구 보존**
|
||||||
|
- 미디어 파일: 계정 삭제 후 **30일 유예** 후 삭제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 프론트엔드 라우트 (React Router v6)
|
||||||
|
|
||||||
|
```
|
||||||
|
/ 홈 (추천 프로젝트, 인기 상품)
|
||||||
|
/projects 프로젝트 탐색 (필터: 칩/난이도/카테고리)
|
||||||
|
/projects/:id 프로젝트 상세 (파일 목록, 부품, 구매 버튼)
|
||||||
|
/shop 상품 목록 (가격/평점 정렬)
|
||||||
|
/shop/:id 상품 상세 + 리뷰
|
||||||
|
/auth/login
|
||||||
|
/auth/register
|
||||||
|
|
||||||
|
/dashboard 판매자 대시보드
|
||||||
|
/dashboard/projects 내 프로젝트 목록 + 상태
|
||||||
|
/dashboard/projects/new 새 프로젝트 작성 (멀티스텝)
|
||||||
|
/dashboard/projects/:id/edit
|
||||||
|
/dashboard/sales 판매·정산 내역
|
||||||
|
/dashboard/orders 구매 내역
|
||||||
|
|
||||||
|
/payment/success 토스 결제 성공 리다이렉트
|
||||||
|
/payment/fail 토스 결제 실패
|
||||||
|
/flash/:token 플래시 실행 페이지
|
||||||
|
|
||||||
|
/admin 관리자 대시보드 (매출 통계)
|
||||||
|
/admin/projects 승인 대기 목록
|
||||||
|
/admin/users 사용자 관리
|
||||||
|
/admin/orders 주문 관리
|
||||||
|
/admin/logs 감사 로그 (필터·CSV 내보내기)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 구현 단계 계획
|
||||||
|
|
||||||
|
### 1단계 — 핵심 (약 2주)
|
||||||
|
- [ ] Docker Compose + Postgres + Redis + MinIO 세팅
|
||||||
|
- [ ] Prisma 스키마 마이그레이션
|
||||||
|
- [ ] JWT 인증 (register/login/me)
|
||||||
|
- [ ] 프로젝트 CRUD + 파일 업로드 (MinIO)
|
||||||
|
- [ ] 관리자 승인/반려
|
||||||
|
- [ ] 상품 목록·상세 페이지 (React)
|
||||||
|
- [ ] 관리자 패널 기본 (승인 대기 목록)
|
||||||
|
|
||||||
|
### 2단계 — 결제·플래시 (약 1.5주)
|
||||||
|
- [ ] 토스페이먼츠 연동 (sandbox)
|
||||||
|
- [ ] 주문 생성·결제 확인·환불
|
||||||
|
- [ ] FlashToken 발급
|
||||||
|
- [ ] webflash 토큰 검증 API
|
||||||
|
- [ ] 플래시 페이지 (/flash/:token)
|
||||||
|
|
||||||
|
### 3단계 — 미디어·리뷰 (약 1주)
|
||||||
|
- [ ] sharp 이미지 압축
|
||||||
|
- [ ] ffmpeg 영상 압축 (백그라운드)
|
||||||
|
- [ ] 리뷰 작성·목록
|
||||||
|
- [ ] 별점 집계 (Product 테이블 업데이트)
|
||||||
|
|
||||||
|
### 4단계 — 운영 도구 (약 1주)
|
||||||
|
- [ ] 감사 로그 뷰어 (관리자)
|
||||||
|
- [ ] 매출 통계 대시보드
|
||||||
|
- [ ] 이메일 알림 (승인/결제/환불)
|
||||||
|
- [ ] Rate Limiting 강화
|
||||||
|
|
||||||
|
### 5단계 — Stripe (별도)
|
||||||
|
- [ ] Stripe Payment Intent 연동
|
||||||
|
- [ ] 환율 처리 (USD 기준)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 보안 체크리스트
|
||||||
|
|
||||||
|
- [ ] JWT 시크릿 환경변수 (rotate 가능)
|
||||||
|
- [ ] 비밀번호 bcrypt (rounds: 12)
|
||||||
|
- [ ] 파일 업로드: MIME 타입 + 매직 바이트 검사
|
||||||
|
- [ ] SQL Injection: Prisma ORM (파라미터 바인딩)
|
||||||
|
- [ ] XSS: React 기본 이스케이프 + DOMPurify (리뷰 내용)
|
||||||
|
- [ ] CORS: ALLOWED_ORIGIN 환경변수
|
||||||
|
- [ ] Rate Limit: Redis, IP당 100req/15min
|
||||||
|
- [ ] 토스 웹훅: HMAC 서명 검증
|
||||||
|
- [ ] 민감 정보 로그 마스킹
|
||||||
|
- [ ] MinIO presigned URL (직접 노출 방지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 환경변수 목록 (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# DB
|
||||||
|
DB_PASSWORD=strongpassword
|
||||||
|
|
||||||
|
# Redis (기본값 사용 가능)
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
MINIO_USER=admin
|
||||||
|
MINIO_PASSWORD=strongpassword
|
||||||
|
|
||||||
|
# App
|
||||||
|
JWT_SECRET=very_long_random_secret
|
||||||
|
BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# 토스페이먼츠
|
||||||
|
TOSS_CLIENT_KEY=test_ck_...
|
||||||
|
TOSS_SECRET_KEY=test_sk_...
|
||||||
|
|
||||||
|
# webflash 연동
|
||||||
|
WEBFLASH_API_URL=http://localhost:3000
|
||||||
|
WEBFLASH_INTERNAL_TOKEN=shared_secret # platform↔webflash 내부 통신용
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 문서를 기반으로 구현을 시작합니다. 단계별 진행 중 변경 사항이 생기면 이 문서를 업데이트합니다.*
|
||||||
19
platform/.env.example
Normal file
19
platform/.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# PostgreSQL
|
||||||
|
DB_PASSWORD=change_this_strong_password
|
||||||
|
|
||||||
|
# MinIO (로컬 S3 스토리지)
|
||||||
|
MINIO_USER=minioadmin
|
||||||
|
MINIO_PASSWORD=change_this_strong_password
|
||||||
|
|
||||||
|
# JWT (openssl rand -hex 64 로 생성 권장)
|
||||||
|
JWT_SECRET=change_this_very_long_random_secret_string
|
||||||
|
|
||||||
|
# 배포 URL (DNS 설정 후 변경)
|
||||||
|
BASE_URL=http://localhost:3200
|
||||||
|
|
||||||
|
# 토스페이먼츠 (https://developers.tosspayments.com 에서 발급)
|
||||||
|
TOSS_CLIENT_KEY=test_ck_YOUR_KEY
|
||||||
|
TOSS_SECRET_KEY=test_sk_YOUR_KEY
|
||||||
|
|
||||||
|
# webflash 연동 내부 토큰 (임의 UUID)
|
||||||
|
WEBFLASH_INTERNAL_TOKEN=change_this_token
|
||||||
94
platform/README.md
Normal file
94
platform/README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# ESP32 DIY 플랫폼
|
||||||
|
|
||||||
|
ESP32 프로젝트 공유·판매 마켓플레이스. webflash(:3100)와 연동되는 별도 서비스입니다.
|
||||||
|
|
||||||
|
## 포트
|
||||||
|
|
||||||
|
| 서비스 | 포트 |
|
||||||
|
|--------|------|
|
||||||
|
| 웹 프론트엔드 | **3200** |
|
||||||
|
| 백엔드 API | **3201** |
|
||||||
|
| MinIO 관리 콘솔 | 9001 |
|
||||||
|
|
||||||
|
## 빠른 시작
|
||||||
|
|
||||||
|
### 1. 환경 변수 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# .env 파일을 편집하여 비밀번호, JWT_SECRET 등 설정
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. DB 마이그레이션 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs platform-backend
|
||||||
|
# "Platform Backend → http://localhost:3201" 메시지 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 관리자 계정 생성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec platform-backend node src/scripts/createAdmin.js admin@example.com yourpassword 관리자
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 브라우저 접속
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:3200
|
||||||
|
```
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
platform/
|
||||||
|
├── backend/
|
||||||
|
│ ├── prisma/schema.prisma DB 스키마
|
||||||
|
│ └── src/
|
||||||
|
│ ├── index.js Express 서버
|
||||||
|
│ ├── config/ DB / Redis / MinIO
|
||||||
|
│ ├── middleware/ Auth / Audit
|
||||||
|
│ ├── routes/ auth / projects / products / admin
|
||||||
|
│ ├── services/ storage (MinIO + sharp)
|
||||||
|
│ └── scripts/ createAdmin.js
|
||||||
|
├── frontend/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── pages/ React 페이지
|
||||||
|
│ │ ├── Auth/ 로그인, 회원가입
|
||||||
|
│ │ ├── Dashboard/ 판매자 대시보드
|
||||||
|
│ │ └── Admin/ 관리자 패널
|
||||||
|
│ ├── hooks/useAuth.js 인증 컨텍스트
|
||||||
|
│ └── api/client.js Axios 인스턴스
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1단계 구현 완료 기능
|
||||||
|
|
||||||
|
- [x] 회원가입 / 로그인 / 로그아웃 (JWT)
|
||||||
|
- [x] 프로젝트 생성 / 수정 / 삭제
|
||||||
|
- [x] 파일 업로드 (이미지 WebP 자동 변환, MinIO 저장)
|
||||||
|
- [x] 관리자 검토 요청 흐름
|
||||||
|
- [x] 관리자 승인/반려 (가격·수수료 설정)
|
||||||
|
- [x] 상품 자동 생성 (승인 시)
|
||||||
|
- [x] 사용자 관리 (역할 변경, 계정 비활성화)
|
||||||
|
- [x] 감사 로그 (모든 주요 행동 기록)
|
||||||
|
|
||||||
|
## 2단계 예정
|
||||||
|
|
||||||
|
- [ ] 토스페이먼츠 결제 연동
|
||||||
|
- [ ] 환불 처리
|
||||||
|
- [ ] FlashToken 발급 및 webflash 연동
|
||||||
|
- [ ] 플래시 완료 로그
|
||||||
|
|
||||||
|
## 3단계 예정
|
||||||
|
|
||||||
|
- [ ] 리뷰 작성 (이미지/영상 업로드)
|
||||||
|
- [ ] ffmpeg 영상 압축
|
||||||
|
- [ ] 별점 집계
|
||||||
19
platform/backend/Dockerfile
Normal file
19
platform/backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# ffmpeg (영상 압축, 2단계에서 사용)
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY prisma ./prisma
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
EXPOSE 3201
|
||||||
|
|
||||||
|
# 마이그레이션 후 서버 시작
|
||||||
|
CMD ["sh", "-c", "npx prisma migrate deploy && node src/index.js"]
|
||||||
32
platform/backend/package.json
Normal file
32
platform/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
202
platform/backend/prisma/schema.prisma
Normal file
202
platform/backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
admin
|
||||||
|
seller
|
||||||
|
buyer
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProjectStatus {
|
||||||
|
draft
|
||||||
|
pending
|
||||||
|
approved
|
||||||
|
rejected
|
||||||
|
suspended
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderStatus {
|
||||||
|
pending
|
||||||
|
paid
|
||||||
|
cancelled
|
||||||
|
refunded
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PaymentGateway {
|
||||||
|
toss
|
||||||
|
stripe
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
nickname String @unique
|
||||||
|
role UserRole @default(buyer)
|
||||||
|
profileImageUrl String?
|
||||||
|
isEmailVerified Boolean @default(false)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
lastLoginIp String?
|
||||||
|
|
||||||
|
projects Project[]
|
||||||
|
orders Order[]
|
||||||
|
reviews Review[]
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
title String
|
||||||
|
description String @db.Text
|
||||||
|
difficultyLevel Int @default(3)
|
||||||
|
requiredParts Json?
|
||||||
|
status ProjectStatus @default(draft)
|
||||||
|
adminNote String?
|
||||||
|
commissionRate Float @default(0.1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
files ProjectFile[]
|
||||||
|
product Product?
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProjectFile {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
fileType String
|
||||||
|
url String
|
||||||
|
thumbnailUrl String?
|
||||||
|
fileSize Int
|
||||||
|
mimeType String
|
||||||
|
originalName String?
|
||||||
|
displayOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
projectId String @unique
|
||||||
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
|
price Int
|
||||||
|
isOnSale Boolean @default(true)
|
||||||
|
totalSales Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
orders Order[]
|
||||||
|
reviews Review[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
buyerId String
|
||||||
|
buyer User @relation(fields: [buyerId], references: [id])
|
||||||
|
productId String
|
||||||
|
product Product @relation(fields: [productId], references: [id])
|
||||||
|
amount Int
|
||||||
|
commissionAmount Int
|
||||||
|
sellerAmount Int
|
||||||
|
paymentGateway PaymentGateway @default(toss)
|
||||||
|
paymentKey String?
|
||||||
|
tossOrderId String? @unique
|
||||||
|
status OrderStatus @default(pending)
|
||||||
|
buyerInfo Json
|
||||||
|
deviceInfo Json?
|
||||||
|
orderedAt DateTime @default(now())
|
||||||
|
paidAt DateTime?
|
||||||
|
refundedAt DateTime?
|
||||||
|
refundReason String?
|
||||||
|
|
||||||
|
flashToken FlashToken?
|
||||||
|
review Review?
|
||||||
|
}
|
||||||
|
|
||||||
|
model FlashToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
token String @unique @default(uuid())
|
||||||
|
orderId String @unique
|
||||||
|
order Order @relation(fields: [orderId], references: [id])
|
||||||
|
isUsed Boolean @default(false)
|
||||||
|
usedAt DateTime?
|
||||||
|
macAddress String?
|
||||||
|
chipFamily String?
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
flashLog FlashLog?
|
||||||
|
}
|
||||||
|
|
||||||
|
model FlashLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
flashTokenId String @unique
|
||||||
|
flashToken FlashToken @relation(fields: [flashTokenId], references: [id])
|
||||||
|
macAddress String
|
||||||
|
chipFamily String
|
||||||
|
firmwareName String
|
||||||
|
firmwareId String
|
||||||
|
success Boolean
|
||||||
|
errorMessage String?
|
||||||
|
clientIp String
|
||||||
|
userAgent String?
|
||||||
|
flashedAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Review {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orderId String @unique
|
||||||
|
order Order @relation(fields: [orderId], references: [id])
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
productId String
|
||||||
|
product Product @relation(fields: [productId], references: [id])
|
||||||
|
rating Int
|
||||||
|
title String
|
||||||
|
content String @db.Text
|
||||||
|
isVisible Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
media ReviewMedia[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ReviewMedia {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
reviewId String
|
||||||
|
review Review @relation(fields: [reviewId], references: [id], onDelete: Cascade)
|
||||||
|
mediaType String
|
||||||
|
url String
|
||||||
|
thumbnailUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
action String
|
||||||
|
targetType String?
|
||||||
|
targetId String?
|
||||||
|
ipAddress String
|
||||||
|
userAgent String?
|
||||||
|
requestMethod String?
|
||||||
|
requestPath String?
|
||||||
|
requestBody Json?
|
||||||
|
responseStatus Int?
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([action])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
7
platform/backend/src/config/db.js
Normal file
7
platform/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;
|
||||||
40
platform/backend/src/config/minio.js
Normal file
40
platform/backend/src/config/minio.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const Minio = require('minio');
|
||||||
|
|
||||||
|
const client = new Minio.Client({
|
||||||
|
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||||
|
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||||
|
useSSL: process.env.MINIO_USE_SSL === 'true',
|
||||||
|
accessKey: process.env.MINIO_ACCESS_KEY,
|
||||||
|
secretKey: process.env.MINIO_SECRET_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const BUCKET = process.env.MINIO_BUCKET || 'platform';
|
||||||
|
|
||||||
|
async function ensureBucket() {
|
||||||
|
const exists = await client.bucketExists(BUCKET);
|
||||||
|
if (!exists) {
|
||||||
|
await client.makeBucket(BUCKET);
|
||||||
|
// 공개 읽기 정책 (이미지·영상 직접 접근용)
|
||||||
|
const policy = JSON.stringify({
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Principal: { AWS: ['*'] },
|
||||||
|
Action: ['s3:GetObject'],
|
||||||
|
Resource: [`arn:aws:s3:::${BUCKET}/*`],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
await client.setBucketPolicy(BUCKET, policy);
|
||||||
|
console.log(`MinIO bucket "${BUCKET}" created`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 업로드 후 공개 URL 반환
|
||||||
|
function publicUrl(objectName) {
|
||||||
|
const proto = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
|
||||||
|
const host = process.env.MINIO_ENDPOINT || 'localhost';
|
||||||
|
const port = process.env.MINIO_PORT || '9000';
|
||||||
|
return `${proto}://${host}:${port}/${BUCKET}/${objectName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { client, BUCKET, ensureBucket, publicUrl };
|
||||||
10
platform/backend/src/config/redis.js
Normal file
10
platform/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;
|
||||||
74
platform/backend/src/index.js
Normal file
74
platform/backend/src/index.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { ensureBucket } = require('./config/minio');
|
||||||
|
const prisma = require('./config/db');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3201;
|
||||||
|
|
||||||
|
// ── 기본 미들웨어 ────────────────────────────────────────────────────────────
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.ALLOWED_ORIGIN || '*',
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
app.use(express.json({ limit: '2mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||||
|
|
||||||
|
// ── Rate Limiting ────────────────────────────────────────────────────────────
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15분
|
||||||
|
max: 200,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' },
|
||||||
|
});
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 20,
|
||||||
|
message: { error: '로그인 시도가 너무 많습니다.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/', limiter);
|
||||||
|
app.use('/api/auth/login', authLimiter);
|
||||||
|
app.use('/api/auth/register', authLimiter);
|
||||||
|
|
||||||
|
// ── 라우트 ───────────────────────────────────────────────────────────────────
|
||||||
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
|
app.use('/api/projects', require('./routes/projects'));
|
||||||
|
app.use('/api/products', require('./routes/products'));
|
||||||
|
app.use('/api/admin', require('./routes/admin'));
|
||||||
|
|
||||||
|
// 헬스 체크
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 오류 핸들러 ─────────────────────────────────────────────────────────────
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
console.error(err);
|
||||||
|
res.status(err.status || 500).json({ error: err.message || '서버 오류' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 시작 ─────────────────────────────────────────────────────────────────────
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
await prisma.$connect();
|
||||||
|
console.log('PostgreSQL connected');
|
||||||
|
|
||||||
|
await ensureBucket();
|
||||||
|
console.log('MinIO ready');
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Platform Backend → http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Startup error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
37
platform/backend/src/middleware/audit.js
Normal file
37
platform/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
platform/backend/src/middleware/auth.js
Normal file
33
platform/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
platform/backend/src/routes/admin.js
Normal file
245
platform/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
platform/backend/src/routes/auth.js
Normal file
134
platform/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;
|
||||||
114
platform/backend/src/routes/products.js
Normal file
114
platform/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;
|
||||||
261
platform/backend/src/routes/projects.js
Normal file
261
platform/backend/src/routes/projects.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
const { body, validationResult } = require('express-validator');
|
||||||
|
const prisma = require('../config/db');
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
const { writeAuditLog } = require('../middleware/audit');
|
||||||
|
const { uploadImage, uploadRaw, deleteObject, IMAGE_TYPES } = require('../services/storage');
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 64 * 1024 * 1024 }, // 64 MB
|
||||||
|
});
|
||||||
|
|
||||||
|
const FILE_WHITELIST = new Set([
|
||||||
|
'image/jpeg', 'image/png', 'image/webp',
|
||||||
|
'application/octet-stream',
|
||||||
|
'application/pdf',
|
||||||
|
'model/stl', 'application/sla',
|
||||||
|
'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── 공개 라우트 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/projects — 승인된 프로젝트 목록
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||||
|
const limit = Math.min(50, parseInt(req.query.limit) || 20);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where = { status: 'approved' };
|
||||||
|
if (req.query.difficulty) where.difficultyLevel = parseInt(req.query.difficulty);
|
||||||
|
|
||||||
|
const [projects, total] = await Promise.all([
|
||||||
|
prisma.project.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, nickname: true } },
|
||||||
|
files: { where: { fileType: 'image' }, take: 1, orderBy: { displayOrder: 'asc' } },
|
||||||
|
product: { select: { id: true, price: true, isOnSale: true, totalSales: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.project.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ projects, total, page, pages: Math.ceil(total / limit) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/projects/:id — 프로젝트 상세
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, nickname: true } },
|
||||||
|
files: { orderBy: { displayOrder: 'asc' } },
|
||||||
|
product: {
|
||||||
|
select: {
|
||||||
|
id: true, price: true, isOnSale: true, totalSales: true,
|
||||||
|
reviews: {
|
||||||
|
where: { isVisible: true },
|
||||||
|
select: { rating: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
|
||||||
|
if (project.status !== 'approved') return res.status(403).json({ error: '비공개 프로젝트입니다' });
|
||||||
|
res.json(project);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 인증 필요 라우트 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/projects/my — 내 프로젝트 목록
|
||||||
|
router.get('/my/list', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
files: { where: { fileType: 'image' }, take: 1 },
|
||||||
|
product: { select: { id: true, price: true, totalSales: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json(projects);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/projects — 새 프로젝트 생성
|
||||||
|
router.post('/', requireAuth, [
|
||||||
|
body('title').trim().isLength({ min: 2, max: 100 }),
|
||||||
|
body('description').trim().isLength({ min: 10 }),
|
||||||
|
body('difficultyLevel').optional().isInt({ min: 1, max: 5 }),
|
||||||
|
], async (req, res) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(400).json({ error: errors.array()[0].msg });
|
||||||
|
|
||||||
|
const { title, description, difficultyLevel, requiredParts } = req.body;
|
||||||
|
try {
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user.id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
difficultyLevel: difficultyLevel ? parseInt(difficultyLevel) : 3,
|
||||||
|
requiredParts: requiredParts || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeAuditLog({ userId: req.user.id, action: 'PROJECT_CREATE', targetType: 'Project', targetId: project.id, req, responseStatus: 201 });
|
||||||
|
res.status(201).json(project);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/projects/:id — 프로젝트 수정 (draft 상태만)
|
||||||
|
router.put('/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = await prisma.project.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
|
||||||
|
if (project.userId !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: '권한이 없습니다' });
|
||||||
|
}
|
||||||
|
if (!['draft', 'rejected'].includes(project.status)) {
|
||||||
|
return res.status(400).json({ error: '승인 대기/완료 상태에서는 수정할 수 없습니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, difficultyLevel, requiredParts } = req.body;
|
||||||
|
const updated = await prisma.project.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: {
|
||||||
|
...(title && { title }),
|
||||||
|
...(description && { description }),
|
||||||
|
...(difficultyLevel && { difficultyLevel: parseInt(difficultyLevel) }),
|
||||||
|
...(requiredParts !== undefined && { requiredParts }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/projects/:id
|
||||||
|
router.delete('/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: { files: true },
|
||||||
|
});
|
||||||
|
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
|
||||||
|
if (project.userId !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: '권한이 없습니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinIO 파일 삭제
|
||||||
|
await Promise.all(project.files.map(f => deleteObject(f.url)));
|
||||||
|
await prisma.project.delete({ where: { id: req.params.id } });
|
||||||
|
await writeAuditLog({ userId: req.user.id, action: 'PROJECT_DELETE', targetType: 'Project', targetId: req.params.id, req, responseStatus: 200 });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/projects/:id/submit — 검토 요청
|
||||||
|
router.post('/:id/submit', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = await prisma.project.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
|
||||||
|
if (project.userId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' });
|
||||||
|
if (!['draft', 'rejected'].includes(project.status)) {
|
||||||
|
return res.status(400).json({ error: '이미 검토 요청 중이거나 승인된 프로젝트입니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.project.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { status: 'pending', adminNote: null },
|
||||||
|
});
|
||||||
|
await writeAuditLog({ userId: req.user.id, action: 'PROJECT_SUBMIT', targetType: 'Project', targetId: project.id, req, responseStatus: 200 });
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/projects/:id/files — 파일 업로드
|
||||||
|
router.post('/:id/files', requireAuth, upload.array('files', 10), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = await prisma.project.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!project) return res.status(404).json({ error: '프로젝트를 찾을 수 없습니다' });
|
||||||
|
if (project.userId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' });
|
||||||
|
if (!req.files?.length) return res.status(400).json({ error: '파일이 없습니다' });
|
||||||
|
|
||||||
|
const fileType = req.body.fileType || 'image'; // image|video|stl|wiring|firmware
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const file of req.files) {
|
||||||
|
let stored;
|
||||||
|
if (IMAGE_TYPES.has(file.mimetype)) {
|
||||||
|
stored = await uploadImage(file.buffer, file.originalname, `projects/${project.id}`);
|
||||||
|
} else {
|
||||||
|
stored = await uploadRaw(file.buffer, file.originalname, file.mimetype, `projects/${project.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pf = await prisma.projectFile.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
fileType,
|
||||||
|
url: stored.url,
|
||||||
|
thumbnailUrl: stored.thumbnailUrl || null,
|
||||||
|
fileSize: stored.fileSize,
|
||||||
|
mimeType: stored.mimeType,
|
||||||
|
originalName: file.originalname,
|
||||||
|
displayOrder: results.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.push(pf);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: '파일 업로드 실패: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/projects/:id/files/:fileId
|
||||||
|
router.delete('/:id/files/:fileId', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const file = await prisma.projectFile.findUnique({ where: { id: req.params.fileId } });
|
||||||
|
if (!file || file.projectId !== req.params.id) return res.status(404).json({ error: '파일을 찾을 수 없습니다' });
|
||||||
|
|
||||||
|
const project = await prisma.project.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (project.userId !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: '권한이 없습니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteObject(file.url);
|
||||||
|
if (file.thumbnailUrl) await deleteObject(file.thumbnailUrl);
|
||||||
|
await prisma.projectFile.delete({ where: { id: req.params.fileId } });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: '서버 오류' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
22
platform/backend/src/scripts/createAdmin.js
Normal file
22
platform/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
platform/backend/src/services/storage.js
Normal file
68
platform/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 };
|
||||||
82
platform/docker-compose.yml
Normal file
82
platform/docker-compose.yml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
services:
|
||||||
|
platform-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- platform-db-data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: platform
|
||||||
|
POSTGRES_USER: platform
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U platform"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
platform-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- platform-redis-data:/data
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
|
||||||
|
platform-minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- platform-storage:/data
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
ports:
|
||||||
|
- "9001:9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
platform-backend:
|
||||||
|
build: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3201:3201"
|
||||||
|
depends_on:
|
||||||
|
platform-db:
|
||||||
|
condition: service_healthy
|
||||||
|
platform-redis:
|
||||||
|
condition: service_started
|
||||||
|
platform-minio:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
PORT: 3201
|
||||||
|
DATABASE_URL: postgresql://platform:${DB_PASSWORD}@platform-db:5432/platform
|
||||||
|
REDIS_URL: redis://platform-redis:6379
|
||||||
|
MINIO_ENDPOINT: platform-minio
|
||||||
|
MINIO_PORT: "9000"
|
||||||
|
MINIO_ACCESS_KEY: ${MINIO_USER}
|
||||||
|
MINIO_SECRET_KEY: ${MINIO_PASSWORD}
|
||||||
|
MINIO_BUCKET: platform
|
||||||
|
MINIO_USE_SSL: "false"
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_EXPIRES_IN: 7d
|
||||||
|
TOSS_CLIENT_KEY: ${TOSS_CLIENT_KEY:-test_ck_placeholder}
|
||||||
|
TOSS_SECRET_KEY: ${TOSS_SECRET_KEY:-test_sk_placeholder}
|
||||||
|
BASE_URL: ${BASE_URL:-http://localhost:3200}
|
||||||
|
WEBFLASH_INTERNAL_TOKEN: ${WEBFLASH_INTERNAL_TOKEN}
|
||||||
|
NODE_ENV: production
|
||||||
|
|
||||||
|
platform-frontend:
|
||||||
|
build: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3200:80"
|
||||||
|
depends_on:
|
||||||
|
- platform-backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
platform-db-data:
|
||||||
|
platform-redis-data:
|
||||||
|
platform-storage:
|
||||||
12
platform/frontend/Dockerfile
Normal file
12
platform/frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
13
platform/frontend/index.html
Normal file
13
platform/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ESP32 DIY 플랫폼</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
platform/frontend/nginx.conf
Normal file
36
platform/frontend/nginx.conf
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 512M;
|
||||||
|
|
||||||
|
# React SPA — 모든 경로를 index.html로 fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 백엔드 API 프록시
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://platform-backend:3201/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 정적 에셋 캐시
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# gzip 압축
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
}
|
||||||
20
platform/frontend/package.json
Normal file
20
platform/frontend/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "platform-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
platform/frontend/src/App.jsx
Normal file
74
platform/frontend/src/App.jsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from './hooks/useAuth';
|
||||||
|
import Navbar from './components/Navbar';
|
||||||
|
|
||||||
|
import Home from './pages/Home';
|
||||||
|
import Projects from './pages/Projects';
|
||||||
|
import ProjectDetail from './pages/ProjectDetail';
|
||||||
|
import Shop from './pages/Shop';
|
||||||
|
import ProductDetail from './pages/ProductDetail';
|
||||||
|
import Login from './pages/Auth/Login';
|
||||||
|
import Register from './pages/Auth/Register';
|
||||||
|
import Dashboard from './pages/Dashboard/Index';
|
||||||
|
import ProjectNew from './pages/Dashboard/ProjectNew';
|
||||||
|
import ProjectEdit from './pages/Dashboard/ProjectEdit';
|
||||||
|
import MyOrders from './pages/Dashboard/MyOrders';
|
||||||
|
import MySales from './pages/Dashboard/MySales';
|
||||||
|
import AdminIndex from './pages/Admin/Index';
|
||||||
|
import AdminProjects from './pages/Admin/Projects';
|
||||||
|
import AdminUsers from './pages/Admin/Users';
|
||||||
|
import AdminLogs from './pages/Admin/Logs';
|
||||||
|
|
||||||
|
function RequireAuth({ children }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <div className="spinner" />;
|
||||||
|
if (!user) return <Navigate to="/auth/login" replace />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequireAdmin({ children }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <div className="spinner" />;
|
||||||
|
if (!user || user.role !== 'admin') return <Navigate to="/" replace />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/projects" element={<Projects />} />
|
||||||
|
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||||
|
<Route path="/shop" element={<Shop />} />
|
||||||
|
<Route path="/shop/:id" element={<ProductDetail />} />
|
||||||
|
<Route path="/auth/login" element={<Login />} />
|
||||||
|
<Route path="/auth/register" element={<Register />} />
|
||||||
|
|
||||||
|
<Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />
|
||||||
|
<Route path="/dashboard/projects/new" element={<RequireAuth><ProjectNew /></RequireAuth>} />
|
||||||
|
<Route path="/dashboard/projects/:id" element={<RequireAuth><ProjectEdit /></RequireAuth>} />
|
||||||
|
<Route path="/dashboard/orders" element={<RequireAuth><MyOrders /></RequireAuth>} />
|
||||||
|
<Route path="/dashboard/sales" element={<RequireAuth><MySales /></RequireAuth>} />
|
||||||
|
|
||||||
|
<Route path="/admin" element={<RequireAdmin><AdminIndex /></RequireAdmin>} />
|
||||||
|
<Route path="/admin/projects" element={<RequireAdmin><AdminProjects /></RequireAdmin>} />
|
||||||
|
<Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} />
|
||||||
|
<Route path="/admin/logs" element={<RequireAdmin><AdminLogs /></RequireAdmin>} />
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRoutes />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
platform/frontend/src/api/client.js
Normal file
28
platform/frontend/src/api/client.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 요청마다 JWT 토큰 자동 첨부
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 401 응답 시 로그아웃 처리
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
43
platform/frontend/src/components/Navbar.jsx
Normal file
43
platform/frontend/src/components/Navbar.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="navbar">
|
||||||
|
<div className="navbar-inner">
|
||||||
|
<NavLink to="/" className="navbar-brand">
|
||||||
|
ESP32 DIY <span>플랫폼</span>
|
||||||
|
</NavLink>
|
||||||
|
<div className="navbar-links">
|
||||||
|
<NavLink to="/projects">프로젝트</NavLink>
|
||||||
|
<NavLink to="/shop">상점</NavLink>
|
||||||
|
{user && <NavLink to="/dashboard">대시보드</NavLink>}
|
||||||
|
{user?.role === 'admin' && <NavLink to="/admin">관리자</NavLink>}
|
||||||
|
</div>
|
||||||
|
<div className="navbar-right">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--text2)', fontSize: 13, alignSelf: 'center' }}>
|
||||||
|
{user.nickname}
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-outline btn-sm" onClick={handleLogout}>로그아웃</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<NavLink to="/auth/login" className="btn btn-outline btn-sm">로그인</NavLink>
|
||||||
|
<NavLink to="/auth/register" className="btn btn-primary btn-sm">회원가입</NavLink>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
platform/frontend/src/hooks/useAuth.js
Normal file
53
platform/frontend/src/hooks/useAuth.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('user')); } catch { return null; }
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) { setLoading(false); return; }
|
||||||
|
api.get('/auth/me')
|
||||||
|
.then(r => { setUser(r.data); localStorage.setItem('user', JSON.stringify(r.data)); })
|
||||||
|
.catch(() => { localStorage.removeItem('token'); localStorage.removeItem('user'); setUser(null); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function login(email, password) {
|
||||||
|
const { data } = await api.post('/auth/login', { email, password });
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
setUser(data.user);
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(email, password, nickname) {
|
||||||
|
const { data } = await api.post('/auth/register', { email, password, nickname });
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
setUser(data.user);
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try { await api.post('/auth/logout'); } catch {}
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
237
platform/frontend/src/index.css
Normal file
237
platform/frontend/src/index.css
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--bg2: #1a1d2e;
|
||||||
|
--bg3: #252840;
|
||||||
|
--border: #2e3150;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--text2: #94a3b8;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent2: #818cf8;
|
||||||
|
--success: #22c55e;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--radius: 8px;
|
||||||
|
--shadow: 0 2px 12px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent2); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; padding: 0 16px; }
|
||||||
|
.page { padding: 32px 0; }
|
||||||
|
|
||||||
|
/* Navbar */
|
||||||
|
.navbar {
|
||||||
|
background: var(--bg2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky; top: 0; z-index: 100;
|
||||||
|
}
|
||||||
|
.navbar-inner {
|
||||||
|
display: flex; align-items: center; gap: 24px;
|
||||||
|
max-width: 1100px; margin: 0 auto; padding: 0 16px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
.navbar-brand { font-weight: 700; font-size: 18px; color: var(--accent2); }
|
||||||
|
.navbar-brand span { color: var(--text2); font-weight: 400; font-size: 13px; }
|
||||||
|
.navbar-links { display: flex; gap: 8px; flex: 1; }
|
||||||
|
.navbar-links a {
|
||||||
|
padding: 6px 12px; border-radius: var(--radius);
|
||||||
|
color: var(--text2); font-size: 14px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.navbar-links a:hover, .navbar-links a.active {
|
||||||
|
background: var(--bg3); color: var(--text); text-decoration: none;
|
||||||
|
}
|
||||||
|
.navbar-right { display: flex; gap: 8px; margin-left: auto; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 8px 16px; border-radius: var(--radius);
|
||||||
|
font-size: 14px; font-weight: 500; border: none;
|
||||||
|
cursor: pointer; transition: opacity .15s, transform .1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: .85; }
|
||||||
|
.btn:active { transform: scale(.97); }
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||||
|
.btn-danger { background: var(--danger); color: #fff; }
|
||||||
|
.btn-success { background: var(--success); color: #fff; }
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 13px; }
|
||||||
|
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.project-card {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color .2s, transform .2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.project-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||||
|
.project-card img {
|
||||||
|
width: 100%; height: 180px; object-fit: cover;
|
||||||
|
background: var(--bg3);
|
||||||
|
}
|
||||||
|
.project-card-body { padding: 16px; }
|
||||||
|
.project-card-title { font-weight: 600; margin-bottom: 6px; }
|
||||||
|
.project-card-meta { color: var(--text2); font-size: 13px; display: flex; gap: 12px; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--text2); }
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
textarea { resize: vertical; min-height: 100px; }
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.alert-error { background: rgba(239,68,68,.15); border: 1px solid var(--danger); color: #fca5a5; }
|
||||||
|
.alert-success { background: rgba(34,197,94,.15); border: 1px solid var(--success); color: #86efac; }
|
||||||
|
.alert-warn { background: rgba(245,158,11,.15); border: 1px solid var(--warn); color: #fcd34d; }
|
||||||
|
.alert-info { background: rgba(99,102,241,.15); border: 1px solid var(--accent); color: var(--accent2); }
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-pending { background: rgba(245,158,11,.2); color: var(--warn); }
|
||||||
|
.badge-approved { background: rgba(34,197,94,.2); color: var(--success); }
|
||||||
|
.badge-rejected { background: rgba(239,68,68,.2); color: var(--danger); }
|
||||||
|
.badge-draft { background: rgba(148,163,184,.2); color: var(--text2); }
|
||||||
|
.badge-suspended { background: rgba(239,68,68,.2); color: var(--danger); }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||||
|
th { color: var(--text2); font-weight: 500; background: var(--bg3); }
|
||||||
|
tr:hover td { background: rgba(255,255,255,.02); }
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination { display: flex; gap: 6px; margin-top: 24px; justify-content: center; }
|
||||||
|
.pagination button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button.active { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.pagination button:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Misc */
|
||||||
|
.stars { color: var(--warn); letter-spacing: 2px; }
|
||||||
|
.divider { height: 1px; background: var(--border); margin: 20px 0; }
|
||||||
|
.text-muted { color: var(--text2); }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.mt-8 { margin-top: 8px; }
|
||||||
|
.mt-16 { margin-top: 16px; }
|
||||||
|
.mt-24 { margin-top: 24px; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-col { flex-direction: column; }
|
||||||
|
.gap-8 { gap: 8px; }
|
||||||
|
.gap-16 { gap: 16px; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
|
.spinner {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .7s linear infinite;
|
||||||
|
margin: 40px auto;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* File Drop Zone */
|
||||||
|
.dropzone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .2s;
|
||||||
|
}
|
||||||
|
.dropzone:hover, .dropzone.over { border-color: var(--accent); }
|
||||||
|
.dropzone p { color: var(--text2); margin-top: 8px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Sidebar layout */
|
||||||
|
.layout-2col { display: grid; grid-template-columns: 220px 1fr; gap: 24px; }
|
||||||
|
.sidebar {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 0;
|
||||||
|
height: fit-content;
|
||||||
|
position: sticky;
|
||||||
|
top: 72px;
|
||||||
|
}
|
||||||
|
.sidebar a {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.sidebar a:hover, .sidebar a.active {
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout-2col { grid-template-columns: 1fr; }
|
||||||
|
.sidebar { position: static; display: flex; flex-wrap: wrap; }
|
||||||
|
.card-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
10
platform/frontend/src/main.jsx
Normal file
10
platform/frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App.jsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
57
platform/frontend/src/pages/Admin/Index.jsx
Normal file
57
platform/frontend/src/pages/Admin/Index.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import api from '../../api/client';
|
||||||
|
|
||||||
|
export default function AdminIndex() {
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/admin/stats').then(r => setStats(r.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cards = stats ? [
|
||||||
|
{ label: '총 사용자', value: stats.users.toLocaleString(), icon: '👤' },
|
||||||
|
{ label: '전체 프로젝트', value: stats.projects.total.toLocaleString(), icon: '📁' },
|
||||||
|
{ label: '승인 대기', value: stats.projects.pending.toLocaleString(), icon: '⏳', warn: stats.projects.pending > 0 },
|
||||||
|
{ label: '총 매출', value: `₩${(stats.revenue.total || 0).toLocaleString()}`, icon: '💰' },
|
||||||
|
{ label: '총 주문', value: stats.revenue.orders.toLocaleString(), icon: '🛒' },
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page">
|
||||||
|
<h2 style={{ marginBottom: 24 }}>관리자 대시보드</h2>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
{stats && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 16, marginBottom: 32 }}>
|
||||||
|
{cards.map(c => (
|
||||||
|
<div key={c.label} className="card" style={{ borderColor: c.warn ? 'var(--warn)' : undefined }}>
|
||||||
|
<div style={{ fontSize: 28, marginBottom: 8 }}>{c.icon}</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, color: c.warn ? 'var(--warn)' : 'var(--text)' }}>{c.value}</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 13 }}>{c.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 빠른 메뉴 */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
|
||||||
|
{[
|
||||||
|
{ to: '/admin/projects', icon: '📋', title: '프로젝트 승인', desc: '검토 대기 중인 프로젝트를 승인/반려' },
|
||||||
|
{ to: '/admin/users', icon: '👥', title: '사용자 관리', desc: '사용자 목록, 역할 변경, 계정 비활성화' },
|
||||||
|
{ to: '/admin/logs', icon: '📜', title: '감사 로그', desc: '모든 주요 행동 기록 조회' },
|
||||||
|
].map(m => (
|
||||||
|
<Link key={m.to} to={m.to} style={{ textDecoration: 'none' }}>
|
||||||
|
<div className="card" style={{ cursor: 'pointer', transition: 'border-color .2s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>{m.icon}</div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>{m.title}</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 13 }}>{m.desc}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
platform/frontend/src/pages/Admin/Logs.jsx
Normal file
95
platform/frontend/src/pages/Admin/Logs.jsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import api from '../../api/client';
|
||||||
|
|
||||||
|
const ACTION_COLORS = {
|
||||||
|
REGISTER: 'var(--success)', LOGIN: 'var(--success)', LOGIN_FAIL: 'var(--danger)',
|
||||||
|
LOGOUT: 'var(--text2)', PROJECT_CREATE: 'var(--accent2)', PROJECT_SUBMIT: 'var(--warn)',
|
||||||
|
PROJECT_DELETE: 'var(--danger)', ADMIN_APPROVE: 'var(--success)', ADMIN_REJECT: 'var(--danger)',
|
||||||
|
ADMIN_USER_DEACTIVATE: 'var(--danger)', ORDER_CREATE: 'var(--accent2)', PAYMENT_CONFIRM: 'var(--success)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminLogs() {
|
||||||
|
const [data, setData] = useState({ logs: [], total: 0, pages: 1 });
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filter, setFilter] = useState({ action: '', userId: '' });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({ page, limit: 50 });
|
||||||
|
if (filter.action) params.set('action', filter.action);
|
||||||
|
if (filter.userId) params.set('userId', filter.userId);
|
||||||
|
api.get(`/admin/logs?${params}`)
|
||||||
|
.then(r => setData(r.data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page">
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2>감사 로그</h2>
|
||||||
|
<span className="text-muted" style={{ fontSize: 13 }}>총 {data.total.toLocaleString()}건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||||
|
<input placeholder="Action 필터 (예: LOGIN)" value={filter.action}
|
||||||
|
onChange={e => setFilter(f => ({ ...f, action: e.target.value }))} style={{ maxWidth: 200 }} />
|
||||||
|
<input placeholder="User ID" value={filter.userId}
|
||||||
|
onChange={e => setFilter(f => ({ ...f, userId: e.target.value }))} style={{ maxWidth: 200 }} />
|
||||||
|
<button className="btn btn-outline" onClick={() => { setPage(1); load(); }}>검색</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <div className="spinner" /> : (
|
||||||
|
<>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>시간</th><th>Action</th><th>사용자</th><th>대상</th><th>IP</th><th>상태</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.logs.map(log => (
|
||||||
|
<tr key={log.id}>
|
||||||
|
<td style={{ fontSize: 12, color: 'var(--text2)', whiteSpace: 'nowrap' }}>
|
||||||
|
{new Date(log.createdAt).toLocaleString('ko-KR')}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code style={{ fontSize: 12, color: ACTION_COLORS[log.action] || 'var(--text)', background: 'var(--bg3)', padding: '2px 6px', borderRadius: 4 }}>
|
||||||
|
{log.action}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 12 }}>
|
||||||
|
{log.user ? `${log.user.nickname} (${log.user.email})` : '-'}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||||
|
{log.targetType ? `${log.targetType} ${log.targetId?.slice(0, 8)}...` : '-'}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 12, color: 'var(--text2)' }}>{log.ipAddress}</td>
|
||||||
|
<td>
|
||||||
|
{log.responseStatus && (
|
||||||
|
<span style={{ fontSize: 12, color: log.responseStatus < 400 ? 'var(--success)' : 'var(--danger)' }}>
|
||||||
|
{log.responseStatus}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{data.pages > 1 && (
|
||||||
|
<div className="pagination">
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
|
||||||
|
{Array.from({ length: Math.min(data.pages, 10) }, (_, i) => (
|
||||||
|
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>{i + 1}</button>
|
||||||
|
))}
|
||||||
|
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
platform/frontend/src/pages/Admin/Projects.jsx
Normal file
132
platform/frontend/src/pages/Admin/Projects.jsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import api from '../../api/client';
|
||||||
|
|
||||||
|
export default function AdminProjects() {
|
||||||
|
const [pending, setPending] = useState([]);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [approveForm, setApproveForm] = useState({ price: '', commissionRate: '0.1' });
|
||||||
|
const [rejectNote, setRejectNote] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/admin/projects/pending')
|
||||||
|
.then(r => setPending(r.data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleApprove() {
|
||||||
|
if (!approveForm.price) { setMsg('판매가를 입력해주세요'); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/projects/${selected.id}/approve`, {
|
||||||
|
price: parseInt(approveForm.price),
|
||||||
|
commissionRate: parseFloat(approveForm.commissionRate),
|
||||||
|
});
|
||||||
|
setPending(p => p.filter(x => x.id !== selected.id));
|
||||||
|
setSelected(null);
|
||||||
|
setMsg('승인되었습니다');
|
||||||
|
} catch (err) {
|
||||||
|
setMsg(err.response?.data?.error || '오류 발생');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject() {
|
||||||
|
if (!rejectNote.trim()) { setMsg('반려 사유를 입력해주세요'); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/projects/${selected.id}/reject`, { adminNote: rejectNote });
|
||||||
|
setPending(p => p.filter(x => x.id !== selected.id));
|
||||||
|
setSelected(null);
|
||||||
|
setRejectNote('');
|
||||||
|
setMsg('반려 처리되었습니다');
|
||||||
|
} catch (err) {
|
||||||
|
setMsg(err.response?.data?.error || '오류 발생');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="spinner" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page">
|
||||||
|
<h2 style={{ marginBottom: 8 }}>프로젝트 승인 관리</h2>
|
||||||
|
<p className="text-muted" style={{ marginBottom: 24 }}>검토 대기 중인 프로젝트: {pending.length}건</p>
|
||||||
|
|
||||||
|
{msg && <div className="alert alert-info" onClick={() => setMsg('')}>{msg}</div>}
|
||||||
|
|
||||||
|
{pending.length === 0 ? (
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<p className="text-muted">검토 대기 중인 프로젝트가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 1fr' : '1fr', gap: 20 }}>
|
||||||
|
{/* 목록 */}
|
||||||
|
<div>
|
||||||
|
{pending.map(p => (
|
||||||
|
<div key={p.id} className="card" style={{ marginBottom: 12, cursor: 'pointer', borderColor: selected?.id === p.id ? 'var(--accent)' : 'var(--border)' }}
|
||||||
|
onClick={() => { setSelected(p); setApproveForm({ price: '', commissionRate: '0.1' }); setRejectNote(''); }}>
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||||
|
{p.files[0] && (
|
||||||
|
<img src={p.files[0].thumbnailUrl || p.files[0].url} alt=""
|
||||||
|
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{p.title}</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||||
|
by {p.user.nickname} · 파일 {p._count.files}개 ·{' '}
|
||||||
|
{new Date(p.updatedAt).toLocaleDateString('ko-KR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 / 승인 패널 */}
|
||||||
|
{selected && (
|
||||||
|
<div className="card" style={{ height: 'fit-content', position: 'sticky', top: 80 }}>
|
||||||
|
<h3 style={{ marginBottom: 16 }}>{selected.title}</h3>
|
||||||
|
<p className="text-muted" style={{ fontSize: 13, marginBottom: 16, maxHeight: 120, overflow: 'auto' }}>
|
||||||
|
{selected.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
<h4 style={{ marginBottom: 12 }}>승인</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>판매가 (원)</label>
|
||||||
|
<input type="number" min="100" placeholder="예: 9900"
|
||||||
|
value={approveForm.price} onChange={e => setApproveForm(f => ({ ...f, price: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>수수료율 (0.0 ~ 1.0)</label>
|
||||||
|
<input type="number" min="0" max="1" step="0.01"
|
||||||
|
value={approveForm.commissionRate}
|
||||||
|
onChange={e => setApproveForm(f => ({ ...f, commissionRate: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-success" onClick={handleApprove} disabled={saving} style={{ width: '100%', marginBottom: 12, justifyContent: 'center' }}>
|
||||||
|
{saving ? '처리 중...' : '✓ 승인하기'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
<h4 style={{ marginBottom: 12 }}>반려</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>반려 사유</label>
|
||||||
|
<textarea value={rejectNote} onChange={e => setRejectNote(e.target.value)} rows={3}
|
||||||
|
placeholder="수정이 필요한 내용을 구체적으로 작성해주세요" />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-danger" onClick={handleReject} disabled={saving} style={{ width: '100%', justifyContent: 'center' }}>
|
||||||
|
{saving ? '처리 중...' : '✕ 반려하기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
platform/frontend/src/pages/Admin/Users.jsx
Normal file
104
platform/frontend/src/pages/Admin/Users.jsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import api from '../../api/client';
|
||||||
|
|
||||||
|
export default function AdminUsers() {
|
||||||
|
const [data, setData] = useState({ users: [], total: 0, pages: 1 });
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({ page, limit: 30 });
|
||||||
|
if (q) params.set('q', q);
|
||||||
|
api.get(`/admin/users?${params}`)
|
||||||
|
.then(r => setData(r.data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [page]);
|
||||||
|
|
||||||
|
async function toggleActive(userId) {
|
||||||
|
await api.put(`/admin/users/${userId}/toggle`);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeRole(userId, role) {
|
||||||
|
if (!confirm(`역할을 "${role}"로 변경하시겠습니까?`)) return;
|
||||||
|
await api.put(`/admin/users/${userId}/role`, { role });
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page">
|
||||||
|
<h2 style={{ marginBottom: 20 }}>사용자 관리</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||||
|
<input placeholder="이메일 또는 닉네임 검색" value={q}
|
||||||
|
onChange={e => setQ(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { setPage(1); load(); } }}
|
||||||
|
style={{ maxWidth: 280 }} />
|
||||||
|
<button className="btn btn-outline" onClick={() => { setPage(1); load(); }}>검색</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <div className="spinner" /> : (
|
||||||
|
<>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>이메일</th><th>닉네임</th><th>역할</th><th>상태</th><th>가입일</th><th>최근 로그인</th><th>역할 변경</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.users.map(u => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td style={{ fontSize: 12 }}>{u.email}</td>
|
||||||
|
<td>{u.nickname}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge badge-${u.role === 'admin' ? 'approved' : u.role === 'seller' ? 'pending' : 'draft'}`}>
|
||||||
|
{u.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ color: u.isActive ? 'var(--success)' : 'var(--danger)', fontSize: 12 }}>
|
||||||
|
{u.isActive ? '활성' : '비활성'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted" style={{ fontSize: 12 }}>{new Date(u.createdAt).toLocaleDateString('ko-KR')}</td>
|
||||||
|
<td className="text-muted" style={{ fontSize: 12 }}>
|
||||||
|
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleDateString('ko-KR') : '-'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select defaultValue={u.role} onChange={e => changeRole(u.id, e.target.value)}
|
||||||
|
style={{ width: 90, padding: '4px 8px', fontSize: 12 }}>
|
||||||
|
<option value="buyer">buyer</option>
|
||||||
|
<option value="seller">seller</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{u.role !== 'admin' && (
|
||||||
|
<button className={`btn btn-sm ${u.isActive ? 'btn-danger' : 'btn-success'}`}
|
||||||
|
onClick={() => toggleActive(u.id)}>
|
||||||
|
{u.isActive ? '비활성화' : '활성화'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{data.pages > 1 && (
|
||||||
|
<div className="pagination">
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
|
||||||
|
{Array.from({ length: data.pages }, (_, i) => (
|
||||||
|
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>{i + 1}</button>
|
||||||
|
))}
|
||||||
|
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
platform/frontend/src/pages/Auth/Login.jsx
Normal file
54
platform/frontend/src/pages/Auth/Login.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [form, setForm] = useState({ email: '', password: '' });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const from = location.state?.from || '/dashboard';
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(form.email, form.password);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || '로그인에 실패했습니다');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page" style={{ maxWidth: 400 }}>
|
||||||
|
<div className="card">
|
||||||
|
<h2 style={{ marginBottom: 24 }}>로그인</h2>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>이메일</label>
|
||||||
|
<input type="email" required value={form.email}
|
||||||
|
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>비밀번호</label>
|
||||||
|
<input type="password" required value={form.password}
|
||||||
|
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||||
|
{loading ? '로그인 중...' : '로그인'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-muted mt-16 text-center" style={{ fontSize: 13 }}>
|
||||||
|
계정이 없으신가요? <Link to="/auth/register">회원가입</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
platform/frontend/src/pages/Auth/Register.jsx
Normal file
61
platform/frontend/src/pages/Auth/Register.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
const [form, setForm] = useState({ email: '', password: '', nickname: '' });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { register } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (form.password.length < 8) {
|
||||||
|
setError('비밀번호는 8자 이상이어야 합니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await register(form.email, form.password, form.nickname);
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || '회원가입에 실패했습니다');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page" style={{ maxWidth: 400 }}>
|
||||||
|
<div className="card">
|
||||||
|
<h2 style={{ marginBottom: 24 }}>회원가입</h2>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>이메일</label>
|
||||||
|
<input type="email" required value={form.email}
|
||||||
|
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>닉네임 (2~30자)</label>
|
||||||
|
<input type="text" required minLength={2} maxLength={30} value={form.nickname}
|
||||||
|
onChange={e => setForm(f => ({ ...f, nickname: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>비밀번호 (8자 이상)</label>
|
||||||
|
<input type="password" required minLength={8} value={form.password}
|
||||||
|
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||||
|
{loading ? '처리 중...' : '회원가입'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-muted mt-16 text-center" style={{ fontSize: 13 }}>
|
||||||
|
이미 계정이 있으신가요? <Link to="/auth/login">로그인</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
platform/frontend/src/pages/Dashboard/Index.jsx
Normal file
75
platform/frontend/src/pages/Dashboard/Index.jsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
import api from '../../api/client';
|
||||||
|
|
||||||
|
const STATUS_BADGE = {
|
||||||
|
draft: <span className="badge badge-draft">임시저장</span>,
|
||||||
|
pending: <span className="badge badge-pending">검토 대기</span>,
|
||||||
|
approved: <span className="badge badge-approved">승인됨</span>,
|
||||||
|
rejected: <span className="badge badge-rejected">반려됨</span>,
|
||||||
|
suspended: <span className="badge badge-suspended">정지됨</span>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/projects/my/list')
|
||||||
|
.then(r => setProjects(r.data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page">
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 24 }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: 22 }}>내 대시보드</h2>
|
||||||
|
<p className="text-muted" style={{ marginTop: 4 }}>안녕하세요, {user?.nickname}님</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/dashboard/projects/new" className="btn btn-primary">+ 새 프로젝트</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||||
|
<Link to="/dashboard/projects/new" className="btn btn-outline btn-sm">프로젝트 등록</Link>
|
||||||
|
<Link to="/dashboard/orders" className="btn btn-outline btn-sm">구매 내역</Link>
|
||||||
|
<Link to="/dashboard/sales" className="btn btn-outline btn-sm">판매 내역</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ marginBottom: 16 }}>내 프로젝트</h3>
|
||||||
|
{loading ? <div className="spinner" /> : (
|
||||||
|
projects.length === 0 ? (
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<p className="text-muted">등록한 프로젝트가 없습니다.</p>
|
||||||
|
<Link to="/dashboard/projects/new" className="btn btn-primary" style={{ marginTop: 16 }}>첫 프로젝트 등록</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>제목</th><th>상태</th><th>판매가</th><th>판매수</th><th>날짜</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map(p => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td>{p.title}</td>
|
||||||
|
<td>{STATUS_BADGE[p.status] || p.status}</td>
|
||||||
|
<td>{p.product ? `₩${p.product.price.toLocaleString()}` : '-'}</td>
|
||||||
|
<td>{p.product?.totalSales ?? '-'}</td>
|
||||||
|
<td className="text-muted">{new Date(p.createdAt).toLocaleDateString('ko-KR')}</td>
|
||||||
|
<td>
|
||||||
|
<Link to={`/dashboard/projects/${p.id}`} className="btn btn-outline btn-sm">관리</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
platform/frontend/src/pages/Dashboard/MyOrders.jsx
Normal file
10
platform/frontend/src/pages/Dashboard/MyOrders.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default function MyOrders() {
|
||||||
|
return (
|
||||||
|
<div className="container page">
|
||||||
|
<h2 style={{ marginBottom: 24 }}>구매 내역</h2>
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<p className="text-muted">결제 기능은 2단계에서 구현됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
platform/frontend/src/pages/Dashboard/MySales.jsx
Normal file
10
platform/frontend/src/pages/Dashboard/MySales.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default function MySales() {
|
||||||
|
return (
|
||||||
|
<div className="container page">
|
||||||
|
<h2 style={{ marginBottom: 24 }}>판매 내역</h2>
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<p className="text-muted">결제 기능은 2단계에서 구현됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
platform/frontend/src/pages/Dashboard/ProjectEdit.jsx
Normal file
175
platform/frontend/src/pages/Dashboard/ProjectEdit.jsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import api from '../../api/client';
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
draft: '임시저장', pending: '검토 대기', approved: '승인됨',
|
||||||
|
rejected: '반려됨', suspended: '정지됨',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProjectEdit() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [project, setProject] = useState(null);
|
||||||
|
const [form, setForm] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
const [fileType, setFileType] = useState('image');
|
||||||
|
const fileRef = useRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get(`/projects/${id}`)
|
||||||
|
.then(r => {
|
||||||
|
setProject(r.data);
|
||||||
|
setForm({ title: r.data.title, description: r.data.description, difficultyLevel: r.data.difficultyLevel });
|
||||||
|
})
|
||||||
|
.catch(() => navigate('/dashboard'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
async function handleSave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.put(`/projects/${id}`, form);
|
||||||
|
setMsg('저장되었습니다');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || '저장 실패');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!files.length) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('fileType', fileType);
|
||||||
|
files.forEach(f => fd.append('files', f));
|
||||||
|
const { data } = await api.post(`/projects/${id}/files`, fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
setProject(p => ({ ...p, files: [...(p.files || []), ...data] }));
|
||||||
|
setFiles([]);
|
||||||
|
setMsg('파일이 업로드되었습니다');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || '업로드 실패');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteFile(fileId) {
|
||||||
|
if (!confirm('파일을 삭제하시겠습니까?')) return;
|
||||||
|
await api.delete(`/projects/${id}/files/${fileId}`);
|
||||||
|
setProject(p => ({ ...p, files: p.files.filter(f => f.id !== fileId) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/projects/${id}/submit`);
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || '제출 실패');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="spinner" />;
|
||||||
|
const canEdit = ['draft', 'rejected'].includes(project?.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page" style={{ maxWidth: 700 }}>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 24 }}>
|
||||||
|
<h2>프로젝트 관리</h2>
|
||||||
|
<span className={`badge badge-${project.status}`}>{STATUS_LABEL[project.status]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.adminNote && (
|
||||||
|
<div className="alert alert-warn">
|
||||||
|
<strong>관리자 메모:</strong> {project.adminNote}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
{msg && <div className="alert alert-success">{msg}</div>}
|
||||||
|
|
||||||
|
{/* 기본 정보 수정 */}
|
||||||
|
<form className="card" onSubmit={handleSave} style={{ marginBottom: 20 }}>
|
||||||
|
<h3 style={{ marginBottom: 16 }}>기본 정보</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>제목</label>
|
||||||
|
<input required disabled={!canEdit} value={form.title || ''}
|
||||||
|
onChange={e => setForm(f => ({ ...f, title: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>설명</label>
|
||||||
|
<textarea disabled={!canEdit} value={form.description || ''}
|
||||||
|
onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={6} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn btn-primary" disabled={!canEdit || saving}>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
{['draft', 'rejected'].includes(project.status) && (
|
||||||
|
<button type="button" className="btn btn-success" onClick={handleSubmit} disabled={saving}>
|
||||||
|
검토 요청
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 파일 관리 */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginBottom: 16 }}>파일 관리</h3>
|
||||||
|
{project.files?.length > 0 && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 8, marginBottom: 20 }}>
|
||||||
|
{project.files.map(f => (
|
||||||
|
<div key={f.id} style={{ position: 'relative', borderRadius: 4, overflow: 'hidden', background: 'var(--bg3)' }}>
|
||||||
|
{f.fileType === 'image'
|
||||||
|
? <img src={f.thumbnailUrl || f.url} alt="" style={{ width: '100%', height: 80, objectFit: 'cover' }} />
|
||||||
|
: <div style={{ height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24 }}>
|
||||||
|
{f.fileType === 'stl' ? '🖨️' : f.fileType === 'firmware' ? '💾' : '📄'}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button onClick={() => handleDeleteFile(f.id)}
|
||||||
|
style={{ position: 'absolute', top: 2, right: 2, background: 'var(--danger)', border: 'none',
|
||||||
|
borderRadius: 4, color: '#fff', cursor: 'pointer', fontSize: 11, padding: '2px 5px' }}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||||
|
<select value={fileType} onChange={e => setFileType(e.target.value)} style={{ width: 160 }}>
|
||||||
|
<option value="image">이미지</option>
|
||||||
|
<option value="video">영상</option>
|
||||||
|
<option value="wiring">배선도</option>
|
||||||
|
<option value="stl">STL</option>
|
||||||
|
<option value="firmware">펌웨어</option>
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-outline" onClick={() => fileRef.current.click()}>파일 선택</button>
|
||||||
|
<input ref={fileRef} type="file" multiple hidden
|
||||||
|
onChange={e => setFiles(Array.from(e.target.files))} />
|
||||||
|
{files.length > 0 && (
|
||||||
|
<button className="btn btn-primary" onClick={handleUpload} disabled={saving}>
|
||||||
|
{saving ? '업로드 중...' : `${files.length}개 업로드`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||||
|
{files.map(f => f.name).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
platform/frontend/src/pages/Dashboard/ProjectNew.jsx
Normal file
212
platform/frontend/src/pages/Dashboard/ProjectNew.jsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import api from '../../api/client';
|
||||||
|
|
||||||
|
export default function ProjectNew() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: '', description: '', difficultyLevel: 3, requiredParts: [],
|
||||||
|
});
|
||||||
|
const [partRow, setPartRow] = useState({ name: '', quantity: '', link: '' });
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
const [fileType, setFileType] = useState('image');
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [step, setStep] = useState(1); // 1:기본정보, 2:파일, 3:완료
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [projectId, setProjectId] = useState(null);
|
||||||
|
const fileRef = useRef();
|
||||||
|
|
||||||
|
function addPart() {
|
||||||
|
if (!partRow.name) return;
|
||||||
|
setForm(f => ({ ...f, requiredParts: [...f.requiredParts, { ...partRow }] }));
|
||||||
|
setPartRow({ name: '', quantity: '', link: '' });
|
||||||
|
}
|
||||||
|
function removePart(i) {
|
||||||
|
setForm(f => ({ ...f, requiredParts: f.requiredParts.filter((_, idx) => idx !== i) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStep1(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/projects', {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
difficultyLevel: form.difficultyLevel,
|
||||||
|
requiredParts: form.requiredParts,
|
||||||
|
});
|
||||||
|
setProjectId(data.id);
|
||||||
|
setStep(2);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || '저장 실패');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!files.length) { setStep(3); return; }
|
||||||
|
setError('');
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('fileType', fileType);
|
||||||
|
files.forEach(f => fd.append('files', f));
|
||||||
|
await api.post(`/projects/${projectId}/files`, fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
setFiles([]);
|
||||||
|
setStep(3);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || '업로드 실패');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForReview() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/projects/${projectId}/submit`);
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || '제출 실패');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
setFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page" style={{ maxWidth: 700 }}>
|
||||||
|
{/* 스텝 표시 */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 32 }}>
|
||||||
|
{['기본 정보', '파일 업로드', '검토 제출'].map((label, i) => (
|
||||||
|
<div key={i} style={{ flex: 1, textAlign: 'center', padding: '8px 0', borderRadius: 'var(--radius)',
|
||||||
|
background: step === i + 1 ? 'var(--accent)' : step > i + 1 ? 'var(--bg3)' : 'var(--bg2)',
|
||||||
|
border: '1px solid var(--border)', color: step === i + 1 ? '#fff' : 'var(--text2)', fontSize: 13 }}>
|
||||||
|
{i + 1}. {label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
{/* 스텝 1 */}
|
||||||
|
{step === 1 && (
|
||||||
|
<form className="card" onSubmit={handleStep1}>
|
||||||
|
<h3 style={{ marginBottom: 20 }}>기본 정보</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>프로젝트 제목 *</label>
|
||||||
|
<input required value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||||
|
placeholder="예: ESP32-S3 CAN FD 로거" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>설명 *</label>
|
||||||
|
<textarea required value={form.description}
|
||||||
|
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||||
|
placeholder="프로젝트 목적, 기능, 특징을 설명해주세요" rows={6} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>난이도</label>
|
||||||
|
<select value={form.difficultyLevel} onChange={e => setForm(f => ({ ...f, difficultyLevel: parseInt(e.target.value) }))}>
|
||||||
|
{[1,2,3,4,5].map(d => <option key={d} value={d}>{d} — {['입문','초급','중급','고급','전문가'][d-1]}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필요 부품 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>필요 부품 (선택)</label>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 1fr auto', gap: 8, marginBottom: 8 }}>
|
||||||
|
<input placeholder="부품명" value={partRow.name} onChange={e => setPartRow(p => ({ ...p, name: e.target.value }))} />
|
||||||
|
<input placeholder="수량" value={partRow.quantity} onChange={e => setPartRow(p => ({ ...p, quantity: e.target.value }))} />
|
||||||
|
<input placeholder="알리/쿠팡 링크" value={partRow.link} onChange={e => setPartRow(p => ({ ...p, link: e.target.value }))} />
|
||||||
|
<button type="button" className="btn btn-outline btn-sm" onClick={addPart}>추가</button>
|
||||||
|
</div>
|
||||||
|
{form.requiredParts.map((p, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, padding: '4px 0' }}>
|
||||||
|
<span style={{ flex: 1 }}>{p.name}</span>
|
||||||
|
<span className="text-muted">{p.quantity}</span>
|
||||||
|
{p.link && <a href={p.link} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>링크</a>}
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => removePart(i)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? '저장 중...' : '다음 단계 →'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 스텝 2 */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginBottom: 20 }}>파일 업로드</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>파일 종류</label>
|
||||||
|
<select value={fileType} onChange={e => setFileType(e.target.value)}>
|
||||||
|
<option value="image">이미지 (jpg, png, webp)</option>
|
||||||
|
<option value="video">영상 (mp4, mov)</option>
|
||||||
|
<option value="wiring">배선도 (jpg, png, pdf)</option>
|
||||||
|
<option value="stl">3D 케이스 STL</option>
|
||||||
|
<option value="firmware">펌웨어 (.bin)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={`dropzone${dragging ? ' over' : ''}`}
|
||||||
|
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onClick={() => fileRef.current.click()}>
|
||||||
|
<input ref={fileRef} type="file" multiple hidden
|
||||||
|
onChange={e => setFiles(prev => [...prev, ...Array.from(e.target.files)])} />
|
||||||
|
<p>파일을 드래그하거나 클릭하여 선택</p>
|
||||||
|
<p>이미지 최대 20MB · 영상 최대 500MB · 펌웨어 최대 64MB</p>
|
||||||
|
</div>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul style={{ marginTop: 12, fontSize: 13, color: 'var(--text2)' }}>
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||||
|
<span>{f.name}</span>
|
||||||
|
<span>{(f.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 20 }}>
|
||||||
|
<button className="btn btn-outline" onClick={() => setStep(1)}>← 이전</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleUpload} disabled={saving}>
|
||||||
|
{saving ? '업로드 중...' : files.length > 0 ? '업로드 후 다음 →' : '건너뛰기 →'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 스텝 3 */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>📋</div>
|
||||||
|
<h3 style={{ marginBottom: 12 }}>관리자 검토 요청</h3>
|
||||||
|
<p className="text-muted" style={{ marginBottom: 24 }}>
|
||||||
|
프로젝트 정보와 파일이 저장되었습니다.<br />
|
||||||
|
관리자 검토 요청을 보내면 승인 후 판매가 시작됩니다.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button className="btn btn-outline" onClick={() => navigate('/dashboard')}>나중에</button>
|
||||||
|
<button className="btn btn-primary" onClick={submitForReview} disabled={saving}>
|
||||||
|
{saving ? '제출 중...' : '검토 요청 제출'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert alert-error" style={{ marginTop: 16 }}>{error}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
platform/frontend/src/pages/Home.jsx
Normal file
102
platform/frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
function Stars({ rating }) {
|
||||||
|
if (!rating) return <span className="text-muted">리뷰 없음</span>;
|
||||||
|
return <span className="stars">{'★'.repeat(Math.round(rating))}{'☆'.repeat(5 - Math.round(rating))}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [products, setProducts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/products?sort=popular&limit=6')
|
||||||
|
.then(r => setProducts(r.data.products || []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Hero */}
|
||||||
|
<div style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)', padding: '60px 0' }}>
|
||||||
|
<div className="container text-center">
|
||||||
|
<h1 style={{ fontSize: 36, marginBottom: 16, color: 'var(--accent2)' }}>
|
||||||
|
ESP32 DIY 플랫폼
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: 'var(--text2)', fontSize: 16, marginBottom: 32, maxWidth: 500, margin: '0 auto 32px' }}>
|
||||||
|
직접 만든 ESP32 프로젝트를 공유하고 판매하세요.<br />
|
||||||
|
펌웨어 구매 후 브라우저에서 바로 플래시까지.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
|
||||||
|
<Link to="/shop" className="btn btn-primary">상점 둘러보기</Link>
|
||||||
|
<Link to="/projects" className="btn btn-outline">프로젝트 탐색</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인기 상품 */}
|
||||||
|
<div className="container page">
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2 style={{ fontSize: 20 }}>인기 상품</h2>
|
||||||
|
<Link to="/shop" className="text-muted" style={{ fontSize: 13 }}>전체 보기 →</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <div className="spinner" /> : (
|
||||||
|
<div className="card-grid">
|
||||||
|
{products.map(p => (
|
||||||
|
<div key={p.id} className="project-card" onClick={() => navigate(`/shop/${p.id}`)}>
|
||||||
|
{p.project.files[0]
|
||||||
|
? <img src={p.project.files[0].thumbnailUrl || p.project.files[0].url} alt={p.project.title} />
|
||||||
|
: <div style={{ height: 180, background: 'var(--bg3)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text2)' }}>이미지 없음</div>
|
||||||
|
}
|
||||||
|
<div className="project-card-body">
|
||||||
|
<div className="project-card-title">{p.project.title}</div>
|
||||||
|
<div className="project-card-meta">
|
||||||
|
<span>{p.project.user.nickname}</span>
|
||||||
|
<Stars rating={p.avgRating} />
|
||||||
|
<span style={{ marginLeft: 'auto', color: 'var(--accent2)', fontWeight: 600 }}>
|
||||||
|
₩{p.price.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{products.length === 0 && !loading && (
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<p className="text-muted">아직 등록된 상품이 없습니다.</p>
|
||||||
|
<Link to="/dashboard/projects/new" className="btn btn-primary" style={{ marginTop: 16 }}>
|
||||||
|
첫 프로젝트 등록하기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 특징 소개 */}
|
||||||
|
<div style={{ background: 'var(--bg2)', borderTop: '1px solid var(--border)', padding: '48px 0' }}>
|
||||||
|
<div className="container">
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 24 }}>
|
||||||
|
{[
|
||||||
|
{ icon: '🔧', title: '프로젝트 공유', desc: '회로도, STL, 부품 목록을 함께 공유' },
|
||||||
|
{ icon: '💸', title: '수익화', desc: '내 프로젝트에서 펌웨어 판매 수익 창출' },
|
||||||
|
{ icon: '⚡', title: '브라우저 플래시', desc: '구매 후 USB 연결만으로 즉시 플래시' },
|
||||||
|
{ icon: '⭐', title: '리뷰 시스템', desc: '완성품 사진·영상으로 제품 평가' },
|
||||||
|
].map(f => (
|
||||||
|
<div key={f.title} className="card" style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 12 }}>{f.icon}</div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 6 }}>{f.title}</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 13 }}>{f.desc}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
platform/frontend/src/pages/ProductDetail.jsx
Normal file
138
platform/frontend/src/pages/ProductDetail.jsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
function Stars({ rating }) {
|
||||||
|
return <span className="stars">{'★'.repeat(rating)}{'☆'.repeat(5 - rating)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [product, setProduct] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [imgIdx, setImgIdx] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get(`/products/${id}`)
|
||||||
|
.then(r => setProduct(r.data))
|
||||||
|
.catch(() => navigate('/shop'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <div className="spinner" />;
|
||||||
|
if (!product) return null;
|
||||||
|
|
||||||
|
const images = product.project.files.filter(f => f.fileType === 'image');
|
||||||
|
const reviews = product.reviews || [];
|
||||||
|
const avgRating = product.avgRating;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page">
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 32 }}>
|
||||||
|
{/* 왼쪽 */}
|
||||||
|
<div>
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<img src={images[imgIdx].url} alt="" style={{ width: '100%', maxHeight: 480, objectFit: 'cover', borderRadius: 'var(--radius)', background: 'var(--bg3)' }} />
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<img key={img.id} src={img.thumbnailUrl || img.url} alt=""
|
||||||
|
onClick={() => setImgIdx(i)}
|
||||||
|
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 4, cursor: 'pointer', border: i === imgIdx ? '2px solid var(--accent)' : '2px solid transparent' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 style={{ fontSize: 24, marginBottom: 8 }}>{product.project.title}</h1>
|
||||||
|
<div className="text-muted" style={{ fontSize: 13, marginBottom: 8 }}>
|
||||||
|
by {product.project.user.nickname}
|
||||||
|
</div>
|
||||||
|
{avgRating && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Stars rating={Math.round(avgRating)} />
|
||||||
|
<span className="text-muted" style={{ fontSize: 13 }}> {avgRating} ({product.reviewCount}개 리뷰)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
<p style={{ color: 'var(--text2)', lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{product.project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 리뷰 */}
|
||||||
|
{reviews.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="divider" />
|
||||||
|
<h3 style={{ marginBottom: 16 }}>구매자 리뷰</h3>
|
||||||
|
{reviews.map(r => (
|
||||||
|
<div key={r.id} className="card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="flex items-center gap-8" style={{ marginBottom: 8 }}>
|
||||||
|
<strong style={{ fontSize: 13 }}>{r.user.nickname}</strong>
|
||||||
|
<Stars rating={r.rating} />
|
||||||
|
<span className="text-muted" style={{ fontSize: 12, marginLeft: 'auto' }}>
|
||||||
|
{new Date(r.createdAt).toLocaleDateString('ko-KR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>{r.title}</div>
|
||||||
|
<p className="text-muted" style={{ fontSize: 13 }}>{r.content}</p>
|
||||||
|
{r.media?.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
|
||||||
|
{r.media.map(m => (
|
||||||
|
m.mediaType === 'image'
|
||||||
|
? <img key={m.id} src={m.thumbnailUrl || m.url} alt="" style={{ width: 80, height: 80, objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
: <a key={m.id} href={m.url} target="_blank" rel="noreferrer" className="btn btn-outline btn-sm">영상 보기</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽 — 구매 패널 */}
|
||||||
|
<div>
|
||||||
|
<div className="card" style={{ position: 'sticky', top: 80 }}>
|
||||||
|
<div style={{ fontSize: 30, fontWeight: 700, color: 'var(--accent2)', marginBottom: 4 }}>
|
||||||
|
₩{product.price.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 13, marginBottom: 20 }}>
|
||||||
|
총 판매 {product.totalSales.toLocaleString()}건
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{product.isOnSale ? (
|
||||||
|
<>
|
||||||
|
{user ? (
|
||||||
|
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center', fontSize: 16 }}
|
||||||
|
onClick={() => alert('결제 기능은 2단계에서 구현됩니다')}>
|
||||||
|
구매하기
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }}
|
||||||
|
onClick={() => navigate('/auth/login', { state: { from: `/shop/${id}` } })}>
|
||||||
|
로그인 후 구매
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="divider" />
|
||||||
|
<ul style={{ color: 'var(--text2)', fontSize: 13, paddingLeft: 16 }}>
|
||||||
|
<li>구매 즉시 플래시 토큰 발급</li>
|
||||||
|
<li>USB 연결 후 브라우저에서 플래시</li>
|
||||||
|
<li>플래시 완료 후 환불 불가</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="alert alert-warn">현재 판매 중지 상태입니다</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
platform/frontend/src/pages/ProjectDetail.jsx
Normal file
130
platform/frontend/src/pages/ProjectDetail.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
const FILE_LABELS = { image: '이미지', video: '영상', stl: 'STL', wiring: '배선도', firmware: '펌웨어' };
|
||||||
|
|
||||||
|
export default function ProjectDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [project, setProject] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [imgIdx, setImgIdx] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get(`/projects/${id}`)
|
||||||
|
.then(r => setProject(r.data))
|
||||||
|
.catch(() => navigate('/projects'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <div className="spinner" />;
|
||||||
|
if (!project) return null;
|
||||||
|
|
||||||
|
const images = project.files.filter(f => f.fileType === 'image');
|
||||||
|
const others = project.files.filter(f => f.fileType !== 'image');
|
||||||
|
const product = project.product;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page">
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 32 }}>
|
||||||
|
{/* 왼쪽 */}
|
||||||
|
<div>
|
||||||
|
{/* 이미지 갤러리 */}
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<img src={images[imgIdx].url} alt="" style={{ width: '100%', maxHeight: 480, objectFit: 'cover', borderRadius: 'var(--radius)', background: 'var(--bg3)' }} />
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<img key={img.id} src={img.thumbnailUrl || img.url} alt=""
|
||||||
|
onClick={() => setImgIdx(i)}
|
||||||
|
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 4, cursor: 'pointer', border: i === imgIdx ? '2px solid var(--accent)' : '2px solid transparent' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 style={{ fontSize: 24, marginBottom: 8 }}>{project.title}</h1>
|
||||||
|
<div className="text-muted" style={{ fontSize: 13, marginBottom: 20 }}>
|
||||||
|
by {project.user.nickname} · 난이도 {'⭐'.repeat(project.difficultyLevel)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
<h3 style={{ marginBottom: 12 }}>프로젝트 설명</h3>
|
||||||
|
<p style={{ color: 'var(--text2)', lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>{project.description}</p>
|
||||||
|
|
||||||
|
{/* 필요 부품 */}
|
||||||
|
{project.requiredParts?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="divider" />
|
||||||
|
<h3 style={{ marginBottom: 12 }}>필요 부품</h3>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>부품명</th><th>수량</th><th>구매처</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{project.requiredParts.map((p, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{p.name}</td>
|
||||||
|
<td>{p.quantity || '-'}</td>
|
||||||
|
<td>{p.link ? <a href={p.link} target="_blank" rel="noreferrer">링크</a> : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 첨부 파일 */}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="divider" />
|
||||||
|
<h3 style={{ marginBottom: 12 }}>첨부 파일</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{others.map(f => (
|
||||||
|
<a key={f.id} href={f.url} target="_blank" rel="noreferrer"
|
||||||
|
className="card" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px' }}>
|
||||||
|
<span style={{ fontSize: 20 }}>
|
||||||
|
{f.fileType === 'stl' ? '🖨️' : f.fileType === 'wiring' ? '🔌' : f.fileType === 'video' ? '🎬' : '📄'}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13 }}>{f.originalName || FILE_LABELS[f.fileType] || f.fileType}</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 12 }}>{(f.fileSize / 1024).toFixed(1)} KB</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽 — 구매 */}
|
||||||
|
<div>
|
||||||
|
{product && product.isOnSale ? (
|
||||||
|
<div className="card" style={{ position: 'sticky', top: 80 }}>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent2)', marginBottom: 8 }}>
|
||||||
|
₩{product.price.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 13, marginBottom: 20 }}>
|
||||||
|
판매 {product.totalSales.toLocaleString()}건
|
||||||
|
</div>
|
||||||
|
<Link to={`/shop/${product.id}`} className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }}>
|
||||||
|
구매하기 →
|
||||||
|
</Link>
|
||||||
|
<p className="text-muted mt-8" style={{ fontSize: 12, textAlign: 'center' }}>
|
||||||
|
구매 후 브라우저에서 바로 플래시 가능
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-muted text-center">이 프로젝트는 현재 판매 중이 아닙니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
platform/frontend/src/pages/Projects.jsx
Normal file
80
platform/frontend/src/pages/Projects.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
const DIFFICULTY = ['', '⭐ 입문', '⭐⭐ 초급', '⭐⭐⭐ 중급', '⭐⭐⭐⭐ 고급', '⭐⭐⭐⭐⭐ 전문가'];
|
||||||
|
|
||||||
|
export default function Projects() {
|
||||||
|
const [data, setData] = useState({ projects: [], total: 0, pages: 1 });
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [difficulty, setDiff] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({ page, limit: 18 });
|
||||||
|
if (difficulty) params.set('difficulty', difficulty);
|
||||||
|
api.get(`/projects?${params}`)
|
||||||
|
.then(r => setData(r.data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [page, difficulty]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page">
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2 style={{ fontSize: 20 }}>ESP32 프로젝트</h2>
|
||||||
|
<select style={{ width: 160 }} value={difficulty} onChange={e => { setDiff(e.target.value); setPage(1); }}>
|
||||||
|
<option value="">난이도 전체</option>
|
||||||
|
{[1,2,3,4,5].map(d => <option key={d} value={d}>{DIFFICULTY[d]}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <div className="spinner" /> : (
|
||||||
|
<>
|
||||||
|
<div className="card-grid">
|
||||||
|
{data.projects.map(p => (
|
||||||
|
<div key={p.id} className="project-card" onClick={() => navigate(`/projects/${p.id}`)}>
|
||||||
|
{p.files[0]
|
||||||
|
? <img src={p.files[0].thumbnailUrl || p.files[0].url} alt={p.title} />
|
||||||
|
: <div style={{ height: 180, background: 'var(--bg3)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text2)' }}>이미지 없음</div>
|
||||||
|
}
|
||||||
|
<div className="project-card-body">
|
||||||
|
<div className="project-card-title">{p.title}</div>
|
||||||
|
<div className="project-card-meta">
|
||||||
|
<span>{p.user.nickname}</span>
|
||||||
|
<span>{DIFFICULTY[p.difficultyLevel]}</span>
|
||||||
|
{p.product && (
|
||||||
|
<span style={{ marginLeft: 'auto', color: 'var(--accent2)', fontWeight: 600 }}>
|
||||||
|
₩{p.product.price.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.projects.length === 0 && (
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<p className="text-muted">등록된 프로젝트가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.pages > 1 && (
|
||||||
|
<div className="pagination">
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
|
||||||
|
{Array.from({ length: data.pages }, (_, i) => (
|
||||||
|
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
platform/frontend/src/pages/Shop.jsx
Normal file
88
platform/frontend/src/pages/Shop.jsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
function Stars({ rating, count }) {
|
||||||
|
if (!rating) return <span className="text-muted" style={{ fontSize: 12 }}>리뷰 없음</span>;
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize: 12 }}>
|
||||||
|
<span className="stars">{'★'.repeat(Math.round(rating))}</span>
|
||||||
|
<span className="text-muted"> {rating} ({count})</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Shop() {
|
||||||
|
const [data, setData] = useState({ products: [], total: 0, pages: 1 });
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [sort, setSort] = useState('popular');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api.get(`/products?page=${page}&limit=18&sort=${sort}`)
|
||||||
|
.then(r => setData(r.data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [page, sort]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container page">
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2 style={{ fontSize: 20 }}>ESP32 상점</h2>
|
||||||
|
<select style={{ width: 160 }} value={sort} onChange={e => { setSort(e.target.value); setPage(1); }}>
|
||||||
|
<option value="popular">인기순</option>
|
||||||
|
<option value="newest">최신순</option>
|
||||||
|
<option value="price_asc">가격 낮은순</option>
|
||||||
|
<option value="price_desc">가격 높은순</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <div className="spinner" /> : (
|
||||||
|
<>
|
||||||
|
<div className="card-grid">
|
||||||
|
{data.products.map(p => (
|
||||||
|
<div key={p.id} className="project-card" onClick={() => navigate(`/shop/${p.id}`)}>
|
||||||
|
{p.project.files[0]
|
||||||
|
? <img src={p.project.files[0].thumbnailUrl || p.project.files[0].url} alt={p.project.title} />
|
||||||
|
: <div style={{ height: 180, background: 'var(--bg3)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text2)' }}>이미지 없음</div>
|
||||||
|
}
|
||||||
|
<div className="project-card-body">
|
||||||
|
<div className="project-card-title">{p.project.title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>
|
||||||
|
by {p.project.user.nickname}
|
||||||
|
</div>
|
||||||
|
<div className="project-card-meta">
|
||||||
|
<Stars rating={p.avgRating} count={p.reviewCount} />
|
||||||
|
<span style={{ marginLeft: 'auto', color: 'var(--accent2)', fontWeight: 700, fontSize: 15 }}>
|
||||||
|
₩{p.price.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.products.length === 0 && (
|
||||||
|
<div className="card text-center" style={{ padding: 48 }}>
|
||||||
|
<p className="text-muted">등록된 상품이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.pages > 1 && (
|
||||||
|
<div className="pagination">
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
|
||||||
|
{Array.from({ length: data.pages }, (_, i) => (
|
||||||
|
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
platform/frontend/vite.config.js
Normal file
11
platform/frontend/vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3201',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user