diff --git a/PLATFORM_DESIGN.md b/PLATFORM_DESIGN.md new file mode 100644 index 0000000..9cd46f7 --- /dev/null +++ b/PLATFORM_DESIGN.md @@ -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 내부 통신용 +``` + +--- + +*이 문서를 기반으로 구현을 시작합니다. 단계별 진행 중 변경 사항이 생기면 이 문서를 업데이트합니다.* diff --git a/platform/.env.example b/platform/.env.example new file mode 100644 index 0000000..a821752 --- /dev/null +++ b/platform/.env.example @@ -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 diff --git a/platform/README.md b/platform/README.md new file mode 100644 index 0000000..758abb6 --- /dev/null +++ b/platform/README.md @@ -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 영상 압축 +- [ ] 별점 집계 diff --git a/platform/backend/Dockerfile b/platform/backend/Dockerfile new file mode 100644 index 0000000..4d8e8c7 --- /dev/null +++ b/platform/backend/Dockerfile @@ -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"] diff --git a/platform/backend/package.json b/platform/backend/package.json new file mode 100644 index 0000000..3c2d624 --- /dev/null +++ b/platform/backend/package.json @@ -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" + } +} diff --git a/platform/backend/prisma/schema.prisma b/platform/backend/prisma/schema.prisma new file mode 100644 index 0000000..d2ee1d7 --- /dev/null +++ b/platform/backend/prisma/schema.prisma @@ -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]) +} diff --git a/platform/backend/src/config/db.js b/platform/backend/src/config/db.js new file mode 100644 index 0000000..50c0e10 --- /dev/null +++ b/platform/backend/src/config/db.js @@ -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; diff --git a/platform/backend/src/config/minio.js b/platform/backend/src/config/minio.js new file mode 100644 index 0000000..a88b0f6 --- /dev/null +++ b/platform/backend/src/config/minio.js @@ -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 }; diff --git a/platform/backend/src/config/redis.js b/platform/backend/src/config/redis.js new file mode 100644 index 0000000..4bad88a --- /dev/null +++ b/platform/backend/src/config/redis.js @@ -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; diff --git a/platform/backend/src/index.js b/platform/backend/src/index.js new file mode 100644 index 0000000..c361800 --- /dev/null +++ b/platform/backend/src/index.js @@ -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(); diff --git a/platform/backend/src/middleware/audit.js b/platform/backend/src/middleware/audit.js new file mode 100644 index 0000000..53941e4 --- /dev/null +++ b/platform/backend/src/middleware/audit.js @@ -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 }; diff --git a/platform/backend/src/middleware/auth.js b/platform/backend/src/middleware/auth.js new file mode 100644 index 0000000..b95123d --- /dev/null +++ b/platform/backend/src/middleware/auth.js @@ -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 }; diff --git a/platform/backend/src/routes/admin.js b/platform/backend/src/routes/admin.js new file mode 100644 index 0000000..8f5af46 --- /dev/null +++ b/platform/backend/src/routes/admin.js @@ -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; diff --git a/platform/backend/src/routes/auth.js b/platform/backend/src/routes/auth.js new file mode 100644 index 0000000..4a349c5 --- /dev/null +++ b/platform/backend/src/routes/auth.js @@ -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; diff --git a/platform/backend/src/routes/products.js b/platform/backend/src/routes/products.js new file mode 100644 index 0000000..ffa0ed6 --- /dev/null +++ b/platform/backend/src/routes/products.js @@ -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; diff --git a/platform/backend/src/routes/projects.js b/platform/backend/src/routes/projects.js new file mode 100644 index 0000000..1feec7e --- /dev/null +++ b/platform/backend/src/routes/projects.js @@ -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; diff --git a/platform/backend/src/scripts/createAdmin.js b/platform/backend/src/scripts/createAdmin.js new file mode 100644 index 0000000..7588481 --- /dev/null +++ b/platform/backend/src/scripts/createAdmin.js @@ -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 '); + 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); }); diff --git a/platform/backend/src/services/storage.js b/platform/backend/src/services/storage.js new file mode 100644 index 0000000..6981dc1 --- /dev/null +++ b/platform/backend/src/services/storage.js @@ -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 }; diff --git a/platform/docker-compose.yml b/platform/docker-compose.yml new file mode 100644 index 0000000..befca23 --- /dev/null +++ b/platform/docker-compose.yml @@ -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: diff --git a/platform/frontend/Dockerfile b/platform/frontend/Dockerfile new file mode 100644 index 0000000..92324c9 --- /dev/null +++ b/platform/frontend/Dockerfile @@ -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;"] diff --git a/platform/frontend/index.html b/platform/frontend/index.html new file mode 100644 index 0000000..f0a9d7b --- /dev/null +++ b/platform/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + ESP32 DIY 플랫폼 + + + +
+ + + diff --git a/platform/frontend/nginx.conf b/platform/frontend/nginx.conf new file mode 100644 index 0000000..f4a1b59 --- /dev/null +++ b/platform/frontend/nginx.conf @@ -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; +} diff --git a/platform/frontend/package.json b/platform/frontend/package.json new file mode 100644 index 0000000..e71bac4 --- /dev/null +++ b/platform/frontend/package.json @@ -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" + } +} diff --git a/platform/frontend/src/App.jsx b/platform/frontend/src/App.jsx new file mode 100644 index 0000000..407e7b1 --- /dev/null +++ b/platform/frontend/src/App.jsx @@ -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
; + if (!user) return ; + return children; +} + +function RequireAdmin({ children }) { + const { user, loading } = useAuth(); + if (loading) return
; + if (!user || user.role !== 'admin') return ; + return children; +} + +function AppRoutes() { + return ( + <> + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + + } /> + + + ); +} + +export default function App() { + return ( + + + + + + ); +} diff --git a/platform/frontend/src/api/client.js b/platform/frontend/src/api/client.js new file mode 100644 index 0000000..8f38bea --- /dev/null +++ b/platform/frontend/src/api/client.js @@ -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; diff --git a/platform/frontend/src/components/Navbar.jsx b/platform/frontend/src/components/Navbar.jsx new file mode 100644 index 0000000..a556e27 --- /dev/null +++ b/platform/frontend/src/components/Navbar.jsx @@ -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 ( + + ); +} diff --git a/platform/frontend/src/hooks/useAuth.js b/platform/frontend/src/hooks/useAuth.js new file mode 100644 index 0000000..c578edb --- /dev/null +++ b/platform/frontend/src/hooks/useAuth.js @@ -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 ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/platform/frontend/src/index.css b/platform/frontend/src/index.css new file mode 100644 index 0000000..9d5a651 --- /dev/null +++ b/platform/frontend/src/index.css @@ -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; } +} diff --git a/platform/frontend/src/main.jsx b/platform/frontend/src/main.jsx new file mode 100644 index 0000000..7497ae8 --- /dev/null +++ b/platform/frontend/src/main.jsx @@ -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( + + + +); diff --git a/platform/frontend/src/pages/Admin/Index.jsx b/platform/frontend/src/pages/Admin/Index.jsx new file mode 100644 index 0000000..18df707 --- /dev/null +++ b/platform/frontend/src/pages/Admin/Index.jsx @@ -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 ( +
+

관리자 대시보드

+ + {/* 통계 카드 */} + {stats && ( +
+ {cards.map(c => ( +
+
{c.icon}
+
{c.value}
+
{c.label}
+
+ ))} +
+ )} + + {/* 빠른 메뉴 */} +
+ {[ + { to: '/admin/projects', icon: '📋', title: '프로젝트 승인', desc: '검토 대기 중인 프로젝트를 승인/반려' }, + { to: '/admin/users', icon: '👥', title: '사용자 관리', desc: '사용자 목록, 역할 변경, 계정 비활성화' }, + { to: '/admin/logs', icon: '📜', title: '감사 로그', desc: '모든 주요 행동 기록 조회' }, + ].map(m => ( + +
e.currentTarget.style.borderColor = 'var(--accent)'} + onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}> +
{m.icon}
+
{m.title}
+
{m.desc}
+
+ + ))} +
+
+ ); +} diff --git a/platform/frontend/src/pages/Admin/Logs.jsx b/platform/frontend/src/pages/Admin/Logs.jsx new file mode 100644 index 0000000..b7a92a2 --- /dev/null +++ b/platform/frontend/src/pages/Admin/Logs.jsx @@ -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 ( +
+
+

감사 로그

+ 총 {data.total.toLocaleString()}건 +
+ +
+ setFilter(f => ({ ...f, action: e.target.value }))} style={{ maxWidth: 200 }} /> + setFilter(f => ({ ...f, userId: e.target.value }))} style={{ maxWidth: 200 }} /> + +
+ + {loading ?
: ( + <> +
+ + + + + + {data.logs.map(log => ( + + + + + + + + + ))} + +
시간Action사용자대상IP상태
+ {new Date(log.createdAt).toLocaleString('ko-KR')} + + + {log.action} + + + {log.user ? `${log.user.nickname} (${log.user.email})` : '-'} + + {log.targetType ? `${log.targetType} ${log.targetId?.slice(0, 8)}...` : '-'} + {log.ipAddress} + {log.responseStatus && ( + + {log.responseStatus} + + )} +
+
+ {data.pages > 1 && ( +
+ + {Array.from({ length: Math.min(data.pages, 10) }, (_, i) => ( + + ))} + +
+ )} + + )} +
+ ); +} diff --git a/platform/frontend/src/pages/Admin/Projects.jsx b/platform/frontend/src/pages/Admin/Projects.jsx new file mode 100644 index 0000000..b7d710c --- /dev/null +++ b/platform/frontend/src/pages/Admin/Projects.jsx @@ -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
; + + return ( +
+

프로젝트 승인 관리

+

검토 대기 중인 프로젝트: {pending.length}건

+ + {msg &&
setMsg('')}>{msg}
} + + {pending.length === 0 ? ( +
+

검토 대기 중인 프로젝트가 없습니다.

+
+ ) : ( +
+ {/* 목록 */} +
+ {pending.map(p => ( +
{ setSelected(p); setApproveForm({ price: '', commissionRate: '0.1' }); setRejectNote(''); }}> +
+ {p.files[0] && ( + + )} +
+
{p.title}
+
+ by {p.user.nickname} · 파일 {p._count.files}개 ·{' '} + {new Date(p.updatedAt).toLocaleDateString('ko-KR')} +
+
+
+
+ ))} +
+ + {/* 상세 / 승인 패널 */} + {selected && ( +
+

{selected.title}

+

+ {selected.description} +

+ +
+

승인

+
+ + setApproveForm(f => ({ ...f, price: e.target.value }))} /> +
+
+ + setApproveForm(f => ({ ...f, commissionRate: e.target.value }))} /> +
+ + +
+

반려

+
+ +