diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index eb6ed05..877db2f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -73,18 +73,19 @@ model Project { } model ProjectFile { - id String @id @default(uuid()) - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - fileType String - url String - thumbnailUrl String? - fileSize Int - mimeType String - originalName String? - flashOffset String @default("0x0") - displayOrder Int @default(0) - createdAt DateTime @default(now()) + id String @id @default(uuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + fileType String + url String + thumbnailUrl String? + fileSize Int + mimeType String + originalName String? + flashOffset String @default("0x0") + firmwareVersion String? // e.g. "1.0.0" — firmware 파일에만 사용 + displayOrder Int @default(0) + createdAt DateTime @default(now()) } model Product { @@ -121,38 +122,79 @@ model Order { refundedAt DateTime? refundReason String? - flashToken FlashToken? - review Review? + flashTokens 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()) + id String @id @default(uuid()) + token String @unique @default(uuid()) + orderId String + order Order @relation(fields: [orderId], references: [id]) + isUsed Boolean @default(false) // 성공적으로 플래시된 경우 true + usedAt DateTime? + macAddress String? + chipFamily String? + expiresAt DateTime + createdAt DateTime @default(now()) + maxAttempts Int @default(3) // 최대 시도 횟수 + attemptCount Int @default(0) // 현재 시도 횟수 (실패 포함) + lastAttemptAt DateTime? + isLocked Boolean @default(false) // maxAttempts 초과 시 잠금 + lockedReason String? + isUpdateToken Boolean @default(false) // 펌웨어 업데이트용 재발급 토큰 - flashLog FlashLog? + flashLogs FlashLog[] + + @@index([orderId]) } 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()) + id String @id @default(uuid()) + flashTokenId String + flashToken FlashToken @relation(fields: [flashTokenId], references: [id]) + attemptNumber Int @default(1) + macAddress String? // 플래시 전 보드에서 읽은 MAC 주소 + chipFamily String? // 칩 종류 (esptool-js 에서 읽음) + chipId String? // 칩 ID + flashSize String? // 플래시 메모리 크기 + firmwareName String + firmwareId String + success Boolean + errorMessage String? + clientIp String + userAgent String? + startedAt DateTime? // 플래시 시작 시각 + completedAt DateTime? // 플래시 완료/실패 시각 + durationMs Int? // 소요 시간 ms + flashedAt DateTime @default(now()) + + @@index([flashTokenId]) + @@index([clientIp]) + @@index([macAddress]) + @@index([flashedAt]) +} + +// 이상 감지 이벤트 +model FlashAnomaly { + id String @id @default(uuid()) + type String // RATE_LIMIT_IP | HIGH_VOLUME_IP | MAC_REUSE | TOKEN_LOCK | SUSPICIOUS_DURATION + severity String @default("medium") // low | medium | high + description String + clientIp String? + macAddress String? + flashTokenId String? + flashLogId String? + metadata Json? + resolved Boolean @default(false) + resolvedAt DateTime? + resolvedBy String? + createdAt DateTime @default(now()) + + @@index([type]) + @@index([clientIp]) + @@index([resolved]) + @@index([createdAt]) } model Review { diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 8f5af46..a52d496 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -242,4 +242,135 @@ router.get('/stats', async (_req, res) => { } }); +// ── 플래시 지표 ───────────────────────────────────────────────────────────── + +// GET /api/admin/flash/metrics — 플래시 통계 대시보드 +router.get('/flash/metrics', async (_req, res) => { + try { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today - 24 * 3600 * 1000); + + const [ + todayTotal, todaySuccess, todayFailed, + allTotal, allSuccess, + unresolvedAnomalies, + recentLogs, + topIps, + ] = await Promise.all([ + prisma.flashLog.count({ where: { flashedAt: { gte: today } } }), + prisma.flashLog.count({ where: { flashedAt: { gte: today }, success: true } }), + prisma.flashLog.count({ where: { flashedAt: { gte: today }, success: false } }), + prisma.flashLog.count(), + prisma.flashLog.count({ where: { success: true } }), + prisma.flashAnomaly.count({ where: { resolved: false } }), + prisma.flashLog.findMany({ + orderBy: { flashedAt: 'desc' }, take: 20, + include: { + flashToken: { + select: { + token: true, isLocked: true, + order: { select: { product: { select: { project: { select: { title: true } } } } } }, + }, + }, + }, + }), + // 상위 IP (24시간) + prisma.flashLog.groupBy({ + by: ['clientIp'], + where: { flashedAt: { gte: yesterday } }, + _count: { clientIp: true }, + orderBy: { _count: { clientIp: 'desc' } }, + take: 10, + }), + ]); + + res.json({ + today: { total: todayTotal, success: todaySuccess, failed: todayFailed }, + allTime: { + total: allTotal, + successRate: allTotal > 0 ? Math.round(allSuccess / allTotal * 100) : 0, + }, + anomalies: { unresolved: unresolvedAnomalies }, + recentLogs: recentLogs.map(l => ({ + id: l.id, + productName: l.flashToken?.order?.product?.project?.title || '?', + macAddress: l.macAddress, + chipFamily: l.chipFamily, + clientIp: l.clientIp, + success: l.success, + durationMs: l.durationMs, + startedAt: l.startedAt, + completedAt: l.completedAt, + flashedAt: l.flashedAt, + errorMessage: l.errorMessage, + isLocked: l.flashToken?.isLocked, + token: l.flashToken?.token?.slice(0, 8) + '…', + })), + topIps: topIps.map(r => ({ ip: r.clientIp, count: r._count.clientIp })), + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: '서버 오류' }); + } +}); + +// GET /api/admin/flash/anomalies +router.get('/flash/anomalies', async (req, res) => { + try { + const resolved = req.query.resolved === 'true'; + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = 50; + const skip = (page - 1) * limit; + + const [anomalies, total] = await Promise.all([ + prisma.flashAnomaly.findMany({ + where: { resolved }, + orderBy: { createdAt: 'desc' }, + skip, take: limit, + }), + prisma.flashAnomaly.count({ where: { resolved } }), + ]); + + res.json({ anomalies, total, page, pages: Math.ceil(total / limit) }); + } catch (err) { + res.status(500).json({ error: '서버 오류' }); + } +}); + +// PUT /api/admin/flash/anomalies/:id/resolve — 이상 이벤트 해소 처리 +router.put('/flash/anomalies/:id/resolve', async (req, res) => { + try { + const updated = await prisma.flashAnomaly.update({ + where: { id: req.params.id }, + data: { resolved: true, resolvedAt: new Date(), resolvedBy: req.user.email }, + }); + res.json(updated); + } catch (err) { + res.status(500).json({ error: '서버 오류' }); + } +}); + +// POST /api/admin/flash/tokens/:id/unlock — 잠긴 토큰 해제 +router.post('/flash/tokens/:id/unlock', async (req, res) => { + try { + const token = await prisma.flashToken.update({ + where: { id: req.params.id }, + data: { + isLocked: false, lockedReason: null, + attemptCount: 0, // 시도 횟수 초기화 + maxAttempts: req.body.maxAttempts || 3, + }, + }); + await writeAuditLog({ + userId: req.user.id, action: 'ADMIN_TOKEN_UNLOCK', + targetType: 'FlashToken', targetId: token.id, + req, responseStatus: 200, + }); + res.json(token); + } catch (err) { + res.status(500).json({ error: '서버 오류' }); + } +}); + module.exports = router; diff --git a/backend/src/routes/flash.js b/backend/src/routes/flash.js index df37437..782561d 100644 --- a/backend/src/routes/flash.js +++ b/backend/src/routes/flash.js @@ -2,7 +2,7 @@ const router = require('express').Router(); const prisma = require('../config/db'); const { writeAuditLog } = require('../middleware/audit'); -// FlashToken 포함 조회 헬퍼 +// ── FlashToken + 펌웨어 파일 포함 조회 ────────────────────────────────────── async function findToken(token) { return prisma.flashToken.findUnique({ where: { token }, @@ -28,24 +28,91 @@ async function findToken(token) { }); } -// GET /api/flash/:token — 토큰 유효성 확인 (Flash 페이지, webflash 둘 다 사용) +// ── 이상 감지 ────────────────────────────────────────────────────────────── +async function detectAnomalies({ clientIp, macAddress, flashTokenId }) { + const now = new Date(); + const t10m = new Date(now - 10 * 60 * 1000); + const t1h = new Date(now - 60 * 60 * 1000); + const alerts = []; + + // 10분 내 동일 IP 5회 이상 + const cnt10m = await prisma.flashLog.count({ + where: { clientIp, startedAt: { gte: t10m } }, + }); + if (cnt10m >= 5) { + alerts.push({ + type: 'RATE_LIMIT_IP', severity: 'high', + description: `IP ${clientIp} — 10분 내 ${cnt10m + 1}회 플래시 시도`, + clientIp, flashTokenId, + }); + } + + // 1시간 내 동일 IP 10회 이상 + const cnt1h = await prisma.flashLog.count({ + where: { clientIp, startedAt: { gte: t1h } }, + }); + if (cnt1h >= 10) { + alerts.push({ + type: 'HIGH_VOLUME_IP', severity: 'medium', + description: `IP ${clientIp} — 1시간 내 ${cnt1h + 1}회 플래시`, + clientIp, flashTokenId, + }); + } + + // 동일 MAC → 1시간 내 3개 이상의 다른 토큰에서 사용 + if (macAddress && macAddress !== 'unknown') { + const macGroups = await prisma.flashLog.groupBy({ + by: ['flashTokenId'], + where: { macAddress, startedAt: { gte: t1h }, flashTokenId: { not: flashTokenId } }, + }); + if (macGroups.length >= 3) { + alerts.push({ + type: 'MAC_REUSE', severity: 'medium', + description: `MAC ${macAddress} — 1시간 내 ${macGroups.length + 1}개 토큰에서 사용`, + clientIp, macAddress, flashTokenId, + }); + } + } + + if (alerts.length > 0) { + await prisma.flashAnomaly.createMany({ data: alerts }); + } + return alerts; +} + +// ── GET /api/flash/:token — 토큰 유효성 + 펌웨어 정보 ─────────────────────── router.get('/:token', async (req, res) => { try { const ft = await findToken(req.params.token); if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' }); - const expired = new Date() > new Date(ft.expiresAt); - const project = ft.order.product.project; + const expired = new Date() > new Date(ft.expiresAt); + const project = ft.order.product.project; + const remaining = Math.max(0, ft.maxAttempts - ft.attemptCount); res.json({ - valid: !expired && !ft.isUsed, - isUsed: ft.isUsed, + valid: !expired && !ft.isUsed && !ft.isLocked, + isUsed: ft.isUsed, expired, - expiresAt: ft.expiresAt, - productName: project.title, - chipFamily: project.chipFamily, - hasFirmware: project.files.length > 0, - usedAt: ft.usedAt, + isLocked: ft.isLocked, + lockedReason: ft.lockedReason, + expiresAt: ft.expiresAt, + productName: project.title, + chipFamily: project.chipFamily, + hasFirmware: project.files.length > 0, + usedAt: ft.usedAt, + attemptsRemaining: remaining, + maxAttempts: ft.maxAttempts, + attemptCount: ft.attemptCount, + isUpdateToken: ft.isUpdateToken, + orderId: ft.orderId, + firmwareFiles: project.files.map(f => ({ + url: f.url, + offset: f.flashOffset || '0x0', + size: f.fileSize, + name: f.originalName || 'firmware.bin', + version: f.firmwareVersion || null, + })), }); } catch (err) { console.error(err); @@ -53,27 +120,22 @@ router.get('/:token', async (req, res) => { } }); -// GET /api/flash/:token/manifest — esp-web-tools 매니페스트 반환 -// 브라우저에서 직접 fetch하므로 CORS 허용 +// ── GET /api/flash/:token/manifest — esp-web-tools 호환 매니페스트 ────────── router.get('/:token/manifest', async (req, res) => { try { const ft = await findToken(req.params.token); if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' }); - - if (ft.isUsed) { - return res.status(410).json({ error: '이미 플래시에 사용된 토큰입니다' }); - } - if (new Date() > new Date(ft.expiresAt)) { - return res.status(410).json({ error: '만료된 토큰입니다' }); - } + if (ft.isUsed) return res.status(410).json({ error: '이미 사용된 토큰' }); + if (new Date() > new Date(ft.expiresAt)) return res.status(410).json({ error: '만료된 토큰' }); + if (ft.isLocked) return res.status(403).json({ error: '잠긴 토큰' }); const project = ft.order.product.project; - if (!project.files.length) { - return res.status(404).json({ error: '펌웨어 파일이 없습니다. 판매자에게 문의하세요.' }); - } + if (!project.files.length) return res.status(404).json({ error: '펌웨어 파일 없음' }); - const manifest = { - name: project.title, + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Content-Type', 'application/json'); + res.json({ + name: project.title, new_install_improv_wait_ms: 0, builds: [{ chipFamily: project.chipFamily, @@ -82,57 +144,157 @@ router.get('/:token/manifest', async (req, res) => { offset: parseInt(f.flashOffset || '0x0', 16), })), }], - }; - - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Content-Type', 'application/json'); - res.json(manifest); + }); } catch (err) { console.error(err); res.status(500).json({ error: '서버 오류' }); } }); -// POST /api/flash/:token/consume — 플래시 완료 기록 -// esp-web-tools 또는 플래시 페이지에서 호출 +// ── POST /api/flash/:token/start — 플래시 시작 기록 ───────────────────────── +router.post('/:token/start', async (req, res) => { + try { + const ft = await findToken(req.params.token); + if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' }); + if (ft.isUsed) return res.status(410).json({ error: '이미 사용된 토큰입니다' }); + if (ft.isLocked) return res.status(403).json({ error: `토큰이 잠겼습니다: ${ft.lockedReason}` }); + if (new Date() > new Date(ft.expiresAt)) return res.status(410).json({ error: '만료된 토큰입니다' }); + + // 시도 횟수 초과 → 잠금 + if (ft.attemptCount >= ft.maxAttempts) { + await prisma.flashToken.update({ + where: { id: ft.id }, + data: { isLocked: true, lockedReason: `최대 시도 횟수(${ft.maxAttempts}회) 초과` }, + }); + return res.status(403).json({ error: '최대 시도 횟수를 초과하여 토큰이 잠겼습니다', attemptsRemaining: 0 }); + } + + const { boardMac, chipType, chipId, flashSize } = req.body; + const clientIp = req.ip || 'unknown'; + const project = ft.order.product.project; + const newCount = ft.attemptCount + 1; + + const flashLog = await prisma.flashLog.create({ + data: { + flashTokenId: ft.id, + attemptNumber: newCount, + macAddress: boardMac || null, + chipFamily: chipType || project.chipFamily, + chipId: chipId || null, + flashSize: flashSize || null, + firmwareName: project.title, + firmwareId: project.id, + success: false, + clientIp, + userAgent: req.headers['user-agent'] || null, + startedAt: new Date(), + }, + }); + + await prisma.flashToken.update({ + where: { id: ft.id }, + data: { attemptCount: newCount, lastAttemptAt: new Date() }, + }); + + // 이상 감지 (비동기) + detectAnomalies({ clientIp, macAddress: boardMac, flashTokenId: ft.id }).catch(() => {}); + + res.json({ + logId: flashLog.id, + attemptsRemaining: ft.maxAttempts - newCount, + maxAttempts: ft.maxAttempts, + attemptNumber: newCount, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: '서버 오류' }); + } +}); + +// ── POST /api/flash/:token/consume — 플래시 완료/실패 기록 ─────────────────── router.post('/:token/consume', async (req, res) => { try { const ft = await findToken(req.params.token); if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' }); - if (ft.isUsed) return res.status(410).json({ error: '이미 사용된 토큰입니다' }); if (new Date() > new Date(ft.expiresAt)) return res.status(410).json({ error: '만료된 토큰입니다' }); - const { mac = 'unknown', chipFamily, success = true, errorMessage } = req.body; - const project = ft.order.product.project; + const { logId, boardMac, chipType, success = true, errorMessage, durationMs } = req.body; + const completedAt = new Date(); + const project = ft.order.product.project; - await prisma.$transaction([ - prisma.flashToken.update({ - where: { id: ft.id }, - data: { isUsed: true, usedAt: new Date(), macAddress: mac, chipFamily: chipFamily || project.chipFamily }, - }), - prisma.flashLog.create({ + // 해당 logId 로그 업데이트 + if (logId) { + await prisma.flashLog.update({ + where: { id: logId }, data: { - flashTokenId: ft.id, - macAddress: mac, - chipFamily: chipFamily || project.chipFamily, - firmwareName: project.title, - firmwareId: project.id, + macAddress: boardMac || undefined, + chipFamily: chipType || undefined, success: Boolean(success), errorMessage: errorMessage || null, - clientIp: req.ip || 'unknown', - userAgent: req.headers['user-agent'] || null, + completedAt, + durationMs: durationMs ? Number(durationMs) : null, }, - }), - ]); + }).catch(() => {}); // logId 없을 수도 있으므로 무시 + } + + // 비정상적으로 짧은 플래시 시간 감지 (5초 미만) + if (success && durationMs && Number(durationMs) < 5000) { + prisma.flashAnomaly.create({ + data: { + type: 'SUSPICIOUS_DURATION', severity: 'low', + description: `플래시 소요시간 ${durationMs}ms — 비정상적으로 짧음`, + clientIp: req.ip || 'unknown', macAddress: boardMac, + flashTokenId: ft.id, flashLogId: logId || null, + metadata: { durationMs: Number(durationMs) }, + }, + }).catch(() => {}); + } + + if (success) { + await prisma.flashToken.update({ + where: { id: ft.id }, + data: { + isUsed: true, usedAt: completedAt, + macAddress: boardMac, chipFamily: chipType || project.chipFamily, + }, + }); + + await writeAuditLog({ + userId: ft.order.buyerId, action: 'FLASH_SUCCESS', + targetType: 'FlashToken', targetId: ft.id, req, + metadata: { boardMac, chipType, firmwareId: project.id, durationMs }, responseStatus: 200, + }); + + return res.json({ success: true, message: '플래시 완료 기록됨' }); + } + + // 실패 처리 + const remaining = ft.maxAttempts - ft.attemptCount; + if (remaining <= 0) { + await prisma.flashToken.update({ + where: { id: ft.id }, + data: { isLocked: true, lockedReason: `최대 시도 횟수(${ft.maxAttempts}회) 초과` }, + }); + prisma.flashAnomaly.create({ + data: { + type: 'TOKEN_LOCK', severity: 'medium', + description: `토큰 잠금 — ${ft.maxAttempts}회 실패 (MAC: ${boardMac || '?'} / IP: ${req.ip})`, + clientIp: req.ip || 'unknown', macAddress: boardMac, flashTokenId: ft.id, + }, + }).catch(() => {}); + } await writeAuditLog({ - userId: ft.order.buyerId, - action: success ? 'FLASH_SUCCESS' : 'FLASH_FAIL', - targetType: 'FlashToken', targetId: ft.id, - req, metadata: { mac, chipFamily, firmwareId: project.id }, responseStatus: 200, + userId: ft.order.buyerId, action: 'FLASH_FAIL', + targetType: 'FlashToken', targetId: ft.id, req, + metadata: { boardMac, chipType, errorMessage, durationMs, remaining }, responseStatus: 200, }); - res.json({ success: true, message: success ? '플래시 완료 기록됨' : '실패 기록됨' }); + res.json({ + success: false, message: '실패 기록됨', + attemptsRemaining: Math.max(0, remaining), + isLocked: remaining <= 0, + }); } catch (err) { console.error(err); res.status(500).json({ error: '서버 오류' }); diff --git a/backend/src/routes/orders.js b/backend/src/routes/orders.js index 187b7fa..b8c1e87 100644 --- a/backend/src/routes/orders.js +++ b/backend/src/routes/orders.js @@ -1,9 +1,14 @@ const router = require('express').Router(); const { v4: uuidv4 } = require('uuid'); const prisma = require('../config/db'); -const { requireAuth, requireRole } = require('../middleware/auth'); +const { requireAuth } = require('../middleware/auth'); const { writeAuditLog } = require('../middleware/audit'); +// 유효한 FlashToken 조회 헬퍼 (만료 전, 미사용, 미잠금) +function activeToken(tokens = []) { + return tokens.find(t => !t.isUsed && !t.isLocked && new Date(t.expiresAt) > new Date()) || null; +} + // POST /api/orders — 주문 생성 (결제 전) router.post('/', requireAuth, async (req, res) => { const { productId } = req.body; @@ -18,15 +23,15 @@ router.post('/', requireAuth, async (req, res) => { if (!product.isOnSale) return res.status(400).json({ error: '판매 중지된 상품입니다' }); if (product.project.userId === req.user.id) return res.status(400).json({ error: '자신의 상품은 구매할 수 없습니다' }); - // 이미 유효한 주문이 있는지 확인 (중복 구매 방지) const existing = await prisma.order.findFirst({ where: { buyerId: req.user.id, productId, status: { in: ['pending', 'paid'] } }, - include: { flashToken: true }, + include: { flashTokens: true }, }); if (existing?.status === 'paid') { + const token = activeToken(existing.flashTokens); return res.status(409).json({ error: '이미 구매한 상품입니다', - flashToken: existing.flashToken?.token, + flashToken: token?.token, orderId: existing.id, }); } @@ -39,15 +44,12 @@ router.post('/', requireAuth, async (req, res) => { const order = await prisma.order.create({ data: { - buyerId: req.user.id, - productId, - amount: product.price, - commissionAmount, - sellerAmount, - paymentGateway: 'toss', - tossOrderId: `mock_${uuidv4()}`, - buyerInfo: { email: req.user.email, nickname: req.user.nickname }, - deviceInfo: { ip: req.ip, userAgent: req.headers['user-agent'] }, + buyerId: req.user.id, productId, + amount: product.price, commissionAmount, sellerAmount, + paymentGateway: 'toss', + tossOrderId: `mock_${uuidv4()}`, + buyerInfo: { email: req.user.email, nickname: req.user.nickname }, + deviceInfo: { ip: req.ip, userAgent: req.headers['user-agent'] }, }, }); @@ -59,33 +61,32 @@ router.post('/', requireAuth, async (req, res) => { } }); -// POST /api/orders/:id/mock-pay — 모의 결제 (사업자 등록 전 테스트용) -// 실제 결제 없이 즉시 paid 처리하고 FlashToken 발급 +// POST /api/orders/:id/mock-pay — 모의 결제 (테스트 모드) router.post('/:id/mock-pay', requireAuth, async (req, res) => { try { const order = await prisma.order.findUnique({ where: { id: req.params.id }, - include: { flashToken: true }, + include: { flashTokens: true }, }); if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' }); if (order.buyerId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' }); + if (order.status === 'paid') { - return res.json({ flashToken: order.flashToken?.token, orderId: order.id, alreadyPaid: true }); + const token = activeToken(order.flashTokens); + return res.json({ flashToken: token?.token, orderId: order.id, alreadyPaid: true }); } if (order.status !== 'pending') { return res.status(400).json({ error: `${order.status} 상태의 주문은 결제할 수 없습니다` }); } - const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30일 + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); - const [updatedOrder, flashToken] = await prisma.$transaction([ + const [, flashToken] = await prisma.$transaction([ prisma.order.update({ where: { id: order.id }, data: { status: 'paid', paidAt: new Date(), paymentKey: `mock_${uuidv4()}` }, }), - prisma.flashToken.create({ - data: { orderId: order.id, expiresAt }, - }), + prisma.flashToken.create({ data: { orderId: order.id, expiresAt } }), prisma.product.update({ where: { id: order.productId }, data: { totalSales: { increment: 1 } }, @@ -105,6 +106,42 @@ router.post('/:id/mock-pay', requireAuth, async (req, res) => { } }); +// POST /api/orders/:id/request-reflash — 펌웨어 업데이트용 재플래시 토큰 발급 +// 결제 완료된 주문에 한해 새 FlashToken 무료 발급 +router.post('/:id/request-reflash', requireAuth, async (req, res) => { + try { + const order = await prisma.order.findUnique({ + where: { id: req.params.id }, + include: { flashTokens: true }, + }); + if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' }); + if (order.buyerId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' }); + if (order.status !== 'paid') return res.status(400).json({ error: '결제 완료 주문만 재플래시가 가능합니다' }); + + // 아직 유효한 토큰이 있으면 그것을 반환 + const existing = activeToken(order.flashTokens); + if (existing) { + return res.json({ token: existing.token, isNew: false, message: '기존 유효 토큰이 있습니다' }); + } + + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const newToken = await prisma.flashToken.create({ + data: { orderId: order.id, expiresAt, isUpdateToken: true }, + }); + + await writeAuditLog({ + userId: req.user.id, action: 'REFLASH_TOKEN_ISSUED', + targetType: 'Order', targetId: order.id, + req, metadata: { tokenId: newToken.id }, responseStatus: 201, + }); + + res.status(201).json({ token: newToken.token, isNew: true, expiresAt, message: '펌웨어 업데이트 토큰이 발급되었습니다' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: '서버 오류' }); + } +}); + // GET /api/orders/me — 내 주문 목록 router.get('/me', requireAuth, async (req, res) => { try { @@ -118,7 +155,7 @@ router.get('/me', requireAuth, async (req, res) => { project: { select: { title: true, chipFamily: true, files: { where: { fileType: 'image' }, take: 1 } } }, }, }, - flashToken: { select: { token: true, isUsed: true, expiresAt: true } }, + flashTokens: { select: { token: true, isUsed: true, isLocked: true, expiresAt: true, attemptCount: true, maxAttempts: true, isUpdateToken: true } }, review: { select: { id: true } }, }, }); @@ -135,7 +172,7 @@ router.get('/:id', requireAuth, async (req, res) => { where: { id: req.params.id }, include: { product: { include: { project: true } }, - flashToken: true, + flashTokens: true, }, }); if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' }); @@ -148,19 +185,21 @@ router.get('/:id', requireAuth, async (req, res) => { } }); -// POST /api/orders/:id/refund — 환불 (모의 / 플래시 미완료 시만) +// POST /api/orders/:id/refund — 환불 (플래시 미완료 시만) router.post('/:id/refund', requireAuth, async (req, res) => { try { const order = await prisma.order.findUnique({ where: { id: req.params.id }, - include: { flashToken: true }, + include: { flashTokens: true }, }); if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' }); if (order.buyerId !== req.user.id && req.user.role !== 'admin') { return res.status(403).json({ error: '권한이 없습니다' }); } if (order.status !== 'paid') return res.status(400).json({ error: '결제 완료 상태가 아닙니다' }); - if (order.flashToken?.isUsed && req.user.role !== 'admin') { + + const hasFlashed = order.flashTokens.some(t => t.isUsed); + if (hasFlashed && req.user.role !== 'admin') { return res.status(400).json({ error: '플래시가 완료된 주문은 환불할 수 없습니다' }); } @@ -169,11 +208,11 @@ router.post('/:id/refund', requireAuth, async (req, res) => { where: { id: order.id }, data: { status: 'refunded', refundedAt: new Date(), refundReason: req.body.reason || '사용자 요청' }, }), - // FlashToken 무효화 - ...(order.flashToken ? [prisma.flashToken.update({ - where: { id: order.flashToken.id }, + // 모든 토큰 만료 처리 + prisma.flashToken.updateMany({ + where: { orderId: order.id }, data: { expiresAt: new Date() }, - })] : []), + }), prisma.product.update({ where: { id: order.productId }, data: { totalSales: { decrement: 1 } }, diff --git a/frontend/package.json b/frontend/package.json index e71bac4..3a3f3d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "axios": "^1.7.2", + "esptool-js": "^0.4.6", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.1" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 408dfec..6b4664c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ import AdminIndex from './pages/Admin/Index'; import AdminProjects from './pages/Admin/Projects'; import AdminUsers from './pages/Admin/Users'; import AdminLogs from './pages/Admin/Logs'; +import FlashMetrics from './pages/Admin/FlashMetrics'; function RequireAuth({ children }) { const { user, loading } = useAuth(); @@ -57,7 +58,8 @@ function AppRoutes() { } /> } /> } /> - } /> + } /> + } /> } /> diff --git a/frontend/src/pages/Admin/FlashMetrics.jsx b/frontend/src/pages/Admin/FlashMetrics.jsx new file mode 100644 index 0000000..1163de4 --- /dev/null +++ b/frontend/src/pages/Admin/FlashMetrics.jsx @@ -0,0 +1,249 @@ +import { useEffect, useState } from 'react'; +import api from '../../api/client'; + +const SEVERITY_COLOR = { high: '#ef4444', medium: '#f59e0b', low: '#6b7280' }; +const ANOMALY_LABELS = { + RATE_LIMIT_IP: 'IP 속도 제한', + HIGH_VOLUME_IP: 'IP 대량 플래시', + MAC_REUSE: 'MAC 재사용', + TOKEN_LOCK: '토큰 잠금', + SUSPICIOUS_DURATION: '비정상 시간', +}; + +function fmtMs(ms) { + if (!ms) return '—'; + return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; +} + +export default function FlashMetrics() { + const [metrics, setMetrics] = useState(null); + const [anomalies, setAnomalies] = useState([]); + const [tab, setTab] = useState('overview'); // overview | logs | anomalies + const [showResolved, setShowResolved] = useState(false); + const [loading, setLoading] = useState(true); + + async function load() { + setLoading(true); + try { + const [mRes, aRes] = await Promise.all([ + api.get('/admin/flash/metrics'), + api.get(`/admin/flash/anomalies?resolved=${showResolved}`), + ]); + setMetrics(mRes.data); + setAnomalies(aRes.data.anomalies || []); + } catch {} + setLoading(false); + } + + useEffect(() => { load(); }, [showResolved]); + + async function resolveAnomaly(id) { + await api.put(`/admin/flash/anomalies/${id}/resolve`).catch(() => {}); + load(); + } + + if (loading) return
; + + const m = metrics; + + return ( +
+
+

플래시 지표

+ +
+ + {/* 요약 카드 */} + {m && ( +
+ {[ + { label: '오늘 전체', value: m.today.total, icon: '⚡' }, + { label: '오늘 성공', value: m.today.success, icon: '✅' }, + { label: '오늘 실패', value: m.today.failed, icon: '❌', warn: m.today.failed > 0 }, + { label: '전체 성공률', value: `${m.allTime.successRate}%`, icon: '📊' }, + { label: '미해소 이상', value: m.anomalies.unresolved, icon: '⚠️', warn: m.anomalies.unresolved > 0 }, + ].map(c => ( +
+
{c.icon}
+
{c.value}
+
{c.label}
+
+ ))} +
+ )} + + {/* 탭 */} +
+ {[ + { id: 'overview', label: '최근 플래시' }, + { id: 'topips', label: 'Top IP' }, + { id: 'anomalies', label: `이상 감지 ${m?.anomalies.unresolved > 0 ? `(${m.anomalies.unresolved})` : ''}` }, + ].map(t => ( + + ))} +
+ + {/* 최근 플래시 로그 */} + {tab === 'overview' && m && ( +
+ + + + {['상품', 'MAC', '칩', 'IP', '소요', '결과', '시각'].map(h => ( + + ))} + + + + {m.recentLogs.map((l, i) => ( + + + + + + + + + + ))} + {m.recentLogs.length === 0 && ( + + )} + +
{h}
{l.productName}{l.macAddress || '—'}{l.chipFamily || '—'}{l.clientIp}{fmtMs(l.durationMs)} + + {l.success ? '성공' : '실패'} + + {l.isLocked && 🔒잠김} + {l.errorMessage && ( +
+ {l.errorMessage.slice(0, 40)} +
+ )} +
+ {l.flashedAt ? new Date(l.flashedAt).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'} +
플래시 기록 없음
+
+ )} + + {/* Top IP */} + {tab === 'topips' && m && ( +
+ + + + + + + + + + {m.topIps.map((r, i) => ( + + + + + + ))} + {m.topIps.length === 0 && ( + + )} + +
IP 주소24시간 내 플래시 횟수상태
{r.ip} +
+ {r.count} +
+
= 10 ? 'var(--danger)' : r.count >= 5 ? 'var(--warn)' : 'var(--accent)', + borderRadius: 3, + }} /> +
+
+
+ {r.count >= 10 ? 고위험 + : r.count >= 5 ? 주의 + : 정상} +
데이터 없음
+
+ )} + + {/* 이상 감지 */} + {tab === 'anomalies' && ( + <> +
+ +
+
+ + + + {['유형', '심각도', '설명', 'IP', 'MAC', '발생', '조치'].map(h => ( + + ))} + + + + {anomalies.map((a, i) => ( + + + + + + + + + + ))} + {anomalies.length === 0 && ( + + )} + +
{h}
+ {ANOMALY_LABELS[a.type] || a.type} + + + {a.severity === 'high' ? '높음' : a.severity === 'medium' ? '중간' : '낮음'} + + {a.description}{a.clientIp || '—'}{a.macAddress || '—'} + {new Date(a.createdAt).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + + {a.resolved ? ( + 해소됨 + ) : ( + + )} +
+ {showResolved ? '이상 이벤트 없음' : '미해소 이상 없음'} +
+
+ + )} +
+ ); +} diff --git a/frontend/src/pages/Admin/Index.jsx b/frontend/src/pages/Admin/Index.jsx index 18df707..6a5d28e 100644 --- a/frontend/src/pages/Admin/Index.jsx +++ b/frontend/src/pages/Admin/Index.jsx @@ -39,7 +39,8 @@ export default function AdminIndex() { {[ { to: '/admin/projects', icon: '📋', title: '프로젝트 승인', desc: '검토 대기 중인 프로젝트를 승인/반려' }, { to: '/admin/users', icon: '👥', title: '사용자 관리', desc: '사용자 목록, 역할 변경, 계정 비활성화' }, - { to: '/admin/logs', icon: '📜', title: '감사 로그', desc: '모든 주요 행동 기록 조회' }, + { to: '/admin/logs', icon: '📜', title: '감사 로그', desc: '모든 주요 행동 기록 조회' }, + { to: '/admin/flash', icon: '⚡', title: '플래시 지표', desc: 'IP·MAC·이상 감지·성공률 실시간 모니터링' }, ].map(m => (
{ - api.get(`/flash/${token}`) - .then(r => setInfo(r.data)) - .catch(() => setInfo(null)) - .finally(() => setLoading(false)); - }, [token]); - - // esp-web-tools 이벤트 — 플래시 완료/실패 시 서버에 기록 - useEffect(() => { - const btn = installRef.current; - if (!btn) return; - - function onSuccess(e) { - const mac = e.detail?.device?.macAddress || 'unknown'; - api.post(`/flash/${token}/consume`, { - mac, chipFamily: info?.chipFamily, success: true, - }).catch(() => {}); - setFlashDone(true); - } - function onFail(e) { - api.post(`/flash/${token}/consume`, { - mac: 'unknown', chipFamily: info?.chipFamily, - success: false, errorMessage: e.detail?.message, - }).catch(() => {}); - } - - btn.addEventListener('state-changed', (e) => { - if (e.detail?.state === 'finished') onSuccess(e); - if (e.detail?.state === 'error') onFail(e); - }); - }, [info, token]); - - if (loading) return
; - - // 유효하지 않은 토큰 - if (!info) { - return ( -
-
-
-

유효하지 않은 토큰

-

토큰을 찾을 수 없습니다.

- - 구매 내역으로 - -
-
- ); +function toBinaryString(buffer) { + const uint8 = new Uint8Array(buffer); + const CHUNK = 65536; + let out = ''; + for (let i = 0; i < uint8.length; i += CHUNK) { + out += String.fromCharCode.apply(null, uint8.subarray(i, i + CHUNK)); } + return out; +} - // 이미 사용된 토큰 - if (info.isUsed) { - return ( -
-
-
-

이미 플래시됨

-

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

-

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

- - 구매 내역으로 - -
-
- ); - } +function fmtMs(ms) { + if (!ms) return '—'; + return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; +} - // 만료된 토큰 - if (info.expired) { - return ( -
-
-
-

만료된 토큰

-

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

-

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

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

펌웨어 파일 없음

-

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

-

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

-
-
- ); - } +const STEP_PHASES = ['connecting', 'board_ready', 'flashing', 'success']; +const STEP_LABELS = ['연결', '보드 확인', '플래시', '완료']; +function StepBar({ phase }) { + const phaseOrder = ['idle', 'connecting', 'reading', 'board_ready', 'downloading', 'flashing', 'success']; + const cur = phaseOrder.indexOf(phase); return ( -
-
-
-
-
-

{info.productName}

- - {CHIP_LABELS[info.chipFamily] || info.chipFamily} · 만료: {new Date(info.expiresAt).toLocaleDateString('ko-KR')} +
+
+ {STEP_PHASES.map((sp, i) => { + const si = phaseOrder.indexOf(sp); + const done = cur > si; + const active = cur === si || (sp === 'connecting' && (phase === 'connecting' || phase === 'reading')); + return ( +
+
+ {done ? '✓' : i + 1} +
+ + {STEP_LABELS[i]}
-
+ ); + })} +
+ ); +} - {flashDone ? ( -
-
🎉
- 플래시 완료! -

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

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

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

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

매니페스트 URL: {manifestUrl}

-
- - )} +function StatusCard({ icon, title, desc, children }) { + return ( +
+
+
{icon}
+

{title}

+

{desc}

+
{children}
+
+
+ ); +} + +function InfoRow({ label, value, mono }) { + return ( +
+
{label}
+
{value || '—'}
+
+ ); +} + +export default function Flash() { + const { token } = useParams(); + + const [info, setInfo] = useState(null); + const [infoLoading, setInfoLoading] = useState(true); + const [phase, setPhase] = useState('idle'); + const [boardInfo, setBoardInfo] = useState(null); + const [progress, setProgress] = useState(0); + const [curFile, setCurFile] = useState(''); + const [error, setError] = useState(null); + const [remaining, setRemaining] = useState(null); + const [duration, setDuration] = useState(null); + const [logs, setLogs] = useState([]); + + const loaderRef = useRef(null); + const transportRef = useRef(null); + const logIdRef = useRef(null); + const startRef = useRef(null); + + const addLog = useCallback((msg) => setLogs(l => [...l.slice(-40), String(msg)]), []); + + // 토큰 정보 로드 + useEffect(() => { + api.get(`/flash/${token}`) + .then(r => { setInfo(r.data); setRemaining(r.data.attemptsRemaining); }) + .catch(() => setInfo(null)) + .finally(() => setInfoLoading(false)); + }, [token]); + + // 1단계: 연결 + 보드 정보 읽기 + async function connectAndRead() { + if (!navigator.serial) { + setError('Web Serial API를 사용할 수 없습니다.\nChrome/Edge + HTTPS(또는 localhost) 환경이 필요합니다.\n\n테스트용: Chrome에서 chrome://flags/#unsafely-treat-insecure-origin-as-secure 설정'); + return; + } + setPhase('connecting'); + setError(null); + setLogs([]); + + let port; + try { + port = await navigator.serial.requestPort(); + } catch (e) { + if (e.name === 'NotFoundError') { setPhase('idle'); return; } + setError(`포트 선택 실패: ${e.message}`); + setPhase('idle'); + return; + } + + try { + const transport = new Transport(port, true); + transportRef.current = transport; + + const loader = new ESPLoader({ + transport, baudrate: BAUD, + terminal: { clean: () => {}, writeLine: addLog, write: addLog }, + }); + loaderRef.current = loader; + + setPhase('reading'); + await loader.main(); + + const macBytes = await loader.chip.get_mac(loader); + const mac = macBytes.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':'); + const chipName = loader.chip.CHIP_NAME; + + setBoardInfo({ mac, chipName }); + addLog(`MAC: ${mac} 칩: ${chipName}`); + + const res = await api.post(`/flash/${token}/start`, { boardMac: mac, chipType: chipName }); + logIdRef.current = res.data.logId; + setRemaining(res.data.attemptsRemaining); + + setPhase('board_ready'); + } catch (err) { + setError(`연결/읽기 실패: ${err.message}`); + setPhase('idle'); + try { await transportRef.current?.disconnect(); } catch {} + loaderRef.current = null; + transportRef.current = null; + } + } + + // 2단계: 펌웨어 다운로드 + 플래시 + async function startFlash() { + setPhase('downloading'); + setProgress(0); + startRef.current = Date.now(); + + try { + const fileArray = []; + for (const f of info.firmwareFiles) { + setCurFile(f.name); + addLog(`다운로드: ${f.name} (${(f.size / 1024).toFixed(1)} KB)`); + const resp = await fetch(f.url); + if (!resp.ok) throw new Error(`다운로드 실패: ${f.name} (HTTP ${resp.status})`); + const buf = await resp.arrayBuffer(); + fileArray.push({ data: toBinaryString(buf), address: parseInt(f.offset || '0x0', 16) }); + } + + setPhase('flashing'); + addLog('플래시 시작...'); + + await loaderRef.current.write_flash({ + fileArray, + flashSize: 'keep', flashMode: 'keep', flashFreq: 'keep', + eraseAll: false, compress: true, + reportProgress: (fi, written, total) => { + setProgress(Math.round(written / total * 100)); + setCurFile(info.firmwareFiles[fi]?.name || ''); + }, + }); + + const ms = Date.now() - startRef.current; + setDuration(ms); + addLog(`완료! 소요시간: ${fmtMs(ms)}`); + + try { await loaderRef.current.hard_reset(); } catch {} + try { await transportRef.current.disconnect(); } catch {} + + await api.post(`/flash/${token}/consume`, { + logId: logIdRef.current, boardMac: boardInfo?.mac, + chipType: boardInfo?.chipName, success: true, durationMs: ms, + }); + + setPhase('success'); + } catch (err) { + const ms = startRef.current ? Date.now() - startRef.current : 0; + setDuration(ms); + try { await transportRef.current?.disconnect(); } catch {} + + const res = await api.post(`/flash/${token}/consume`, { + logId: logIdRef.current, boardMac: boardInfo?.mac, + chipType: boardInfo?.chipName, success: false, + errorMessage: err.message, durationMs: ms, + }).catch(() => null); + + const left = res?.data?.attemptsRemaining ?? 0; + setRemaining(left); + setError(`플래시 실패: ${err.message}`); + setPhase(left <= 0 ? 'locked' : 'failed'); + + loaderRef.current = null; + transportRef.current = null; + logIdRef.current = null; + } + } + + function retry() { + setBoardInfo(null); setProgress(0); setError(null); + setLogs([]); setCurFile(''); setPhase('idle'); + loaderRef.current = null; transportRef.current = null; + logIdRef.current = null; startRef.current = null; + api.get(`/flash/${token}`) + .then(r => { setInfo(r.data); setRemaining(r.data.attemptsRemaining); }) + .catch(() => {}); + } + + async function requestReflash() { + try { + const res = await api.post(`/orders/${info.orderId}/request-reflash`); + window.location.href = `/flash/${res.data.token}`; + } catch (err) { + setError(`재플래시 토큰 발급 실패: ${err.response?.data?.error || err.message}`); + } + } + + // ── 렌더링 ────────────────────────────────────────────────────────────────── + if (infoLoading) return
; + if (!info) { + return ( + + 구매 내역으로 + + ); + } + if (info.expired) { + return ( + + {info.orderId && } + 구매 내역으로 + + ); + } + if (info.isUsed && phase !== 'success') { + return ( + + {info.orderId && } + 구매 내역으로 + + ); + } + if (info.isLocked && phase !== 'locked') { + return ( + + 구매 내역으로 + + ); + } + if (!info.hasFirmware) { + return ( + + 구매 내역으로 + + ); + } + + return ( +
+
+ + {/* 헤더 */} +
+
+
+

{info.productName}

+ + {info.chipFamily} · 만료: {new Date(info.expiresAt).toLocaleDateString('ko-KR')} + {info.isUpdateToken && ( + 업데이트 + )} + +
+
+ + + + {/* 남은 시도 횟수 경고 */} + {remaining !== null && remaining < info.maxAttempts && phase !== 'success' && phase !== 'locked' && ( +
+ 남은 시도: {remaining}/{info.maxAttempts}회 + {remaining <= 1 && ' — 마지막 기회입니다!'} +
+ )} + + {/* 보드 정보 카드 */} + {boardInfo && phase !== 'success' && ( +
+
+ 보드 정보 (esptool 읽기) +
+
+ + +
+
+ )} + + {/* 진행 바 */} + {(phase === 'downloading' || phase === 'flashing') && ( +
+
+ {phase === 'downloading' ? '다운로드' : '기록'}: {curFile} + {progress}% +
+
+
+
+
+ )} + + {/* 에러 */} + {error && ( +
+ {error} +
+ )} + + {/* 성공 */} + {phase === 'success' && ( +
+
🎉
+
플래시 완료!
+ {boardInfo &&

MAC: {boardInfo.mac} · 칩: {boardInfo.chipName}

} + {duration &&

소요시간: {fmtMs(duration)}

} +
+ + 구매 내역 +
+
+ )} + + {/* 잠금 */} + {phase === 'locked' && ( +
+
🔒
+
토큰이 잠겼습니다
+

최대 시도 횟수를 초과했습니다.

+

관리자에게 문의하시면 잠금 해제가 가능합니다.

+ 구매 내역으로 +
+ )} + + {/* 액션: 초기 상태 */} + {phase === 'idle' && ( + <> +
+ 플래시 전 확인사항 +
    +
  • Chrome 또는 Edge 브라우저 사용 중인가요?
  • +
  • ESP32가 USB로 PC에 연결되어 있나요?
  • +
  • HTTPS 또는 localhost 접속인가요? (Web Serial 필수)
  • +
+
+
+ +

버튼 클릭 → 팝업에서 포트 선택

+
+ + )} + + {/* 연결/읽기 중 */} + {(phase === 'connecting' || phase === 'reading') && ( +
+
+

+ {phase === 'connecting' ? 'ESP32 연결 중…' : '보드 정보 읽는 중…'} +

+
+ )} + + {/* 보드 확인 완료 → 플래시 버튼 */} + {phase === 'board_ready' && ( +
+ + +
+ )} + + {/* 다운로드/플래시 중 */} + {(phase === 'downloading' || phase === 'flashing') && ( +
+

+ {phase === 'downloading' ? '펌웨어 다운로드 중…' : '플래시 기록 중… USB 케이블을 뽑지 마세요'} +

+
+ )} + + {/* 실패 → 재시도 */} + {phase === 'failed' && ( +
+ {remaining > 0 && ( + + )} + 구매 내역으로 +
+ )} + + {/* ESP 디버그 로그 */} + {logs.length > 0 && ( +
+ + ESP 로그 ({logs.length}줄) + +
+ {logs.join('\n')} +
+
+ )} + +
+
+ 토큰 정보 + {token} +

펌웨어: {info.firmwareFiles?.length}개 파일

+
+
);