feat: board info logging, anomaly detection, re-flash & firmware update tokens
- Flash.jsx: replace esp-web-tools with direct esptool-js integration → reads MAC address + chip type before flash via Web Serial API → step-by-step UI (connect → board info → download → flash → done) → retry button on failure with remaining-attempt counter → firmware update token request after successful flash - Schema: FlashToken (maxAttempts/attemptCount/isLocked/isUpdateToken), FlashLog (startedAt/completedAt/durationMs/chipId/flashSize), FlashAnomaly model (RATE_LIMIT_IP/HIGH_VOLUME_IP/MAC_REUSE/TOKEN_LOCK/SUSPICIOUS_DURATION), ProjectFile.firmwareVersion - flash.js: new POST /start (board info + IP log + anomaly detection), updated POST /consume (timing, lock on exhaustion), GET returns firmwareFiles - orders.js: POST /request-reflash (free firmware update token for paid orders), updated to flashTokens[] relation - admin.js: GET /flash/metrics, GET/PUT /flash/anomalies, POST /flash/tokens/:id/unlock - Admin/FlashMetrics.jsx: dashboard with today stats, recent logs table, top-IP chart, anomaly management with resolve button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '서버 오류' });
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/admin" element={<RequireAdmin><AdminIndex /></RequireAdmin>} />
|
||||
<Route path="/admin/projects" element={<RequireAdmin><AdminProjects /></RequireAdmin>} />
|
||||
<Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} />
|
||||
<Route path="/admin/logs" element={<RequireAdmin><AdminLogs /></RequireAdmin>} />
|
||||
<Route path="/admin/logs" element={<RequireAdmin><AdminLogs /></RequireAdmin>} />
|
||||
<Route path="/admin/flash" element={<RequireAdmin><FlashMetrics /></RequireAdmin>} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
249
frontend/src/pages/Admin/FlashMetrics.jsx
Normal file
249
frontend/src/pages/Admin/FlashMetrics.jsx
Normal file
@@ -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 <div className="spinner" />;
|
||||
|
||||
const m = metrics;
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h2>플래시 지표</h2>
|
||||
<button className="btn btn-outline btn-sm" onClick={load}>새로고침</button>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
{m && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 14, marginBottom: 28 }}>
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={c.label} className="card" style={{ borderColor: c.warn ? 'var(--warn)' : undefined }}>
|
||||
<div style={{ fontSize: 24, marginBottom: 6 }}>{c.icon}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: c.warn ? 'var(--warn)' : undefined }}>{c.value}</div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>{c.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 탭 */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
|
||||
{[
|
||||
{ id: 'overview', label: '최근 플래시' },
|
||||
{ id: 'topips', label: 'Top IP' },
|
||||
{ id: 'anomalies', label: `이상 감지 ${m?.anomalies.unresolved > 0 ? `(${m.anomalies.unresolved})` : ''}` },
|
||||
].map(t => (
|
||||
<button key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
style={{
|
||||
padding: '8px 16px', background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: tab === t.id ? 'var(--accent)' : 'var(--text2)',
|
||||
borderBottom: tab === t.id ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
marginBottom: -1, fontWeight: tab === t.id ? 600 : 400, fontSize: 14,
|
||||
}}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 최근 플래시 로그 */}
|
||||
{tab === 'overview' && m && (
|
||||
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
|
||||
{['상품', 'MAC', '칩', 'IP', '소요', '결과', '시각'].map(h => (
|
||||
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--text2)', fontWeight: 500 }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{m.recentLogs.map((l, i) => (
|
||||
<tr key={l.id} style={{ borderBottom: '1px solid var(--border)', background: i % 2 === 1 ? 'var(--bg2)' : undefined }}>
|
||||
<td style={{ padding: '9px 14px' }}>{l.productName}</td>
|
||||
<td style={{ padding: '9px 14px', fontFamily: 'monospace', fontSize: 12 }}>{l.macAddress || '—'}</td>
|
||||
<td style={{ padding: '9px 14px', fontSize: 12 }}>{l.chipFamily || '—'}</td>
|
||||
<td style={{ padding: '9px 14px', fontFamily: 'monospace', fontSize: 12 }}>{l.clientIp}</td>
|
||||
<td style={{ padding: '9px 14px' }}>{fmtMs(l.durationMs)}</td>
|
||||
<td style={{ padding: '9px 14px' }}>
|
||||
<span style={{
|
||||
color: l.success ? 'var(--success)' : 'var(--danger)',
|
||||
fontWeight: 600, fontSize: 12,
|
||||
}}>
|
||||
{l.success ? '성공' : '실패'}
|
||||
</span>
|
||||
{l.isLocked && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--warn)' }}>🔒잠김</span>}
|
||||
{l.errorMessage && (
|
||||
<div style={{ color: 'var(--danger)', fontSize: 11, marginTop: 2 }}>
|
||||
{l.errorMessage.slice(0, 40)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '9px 14px', fontSize: 12, color: 'var(--text2)', whiteSpace: 'nowrap' }}>
|
||||
{l.flashedAt ? new Date(l.flashedAt).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{m.recentLogs.length === 0 && (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 40, color: 'var(--text2)' }}>플래시 기록 없음</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top IP */}
|
||||
{tab === 'topips' && m && (
|
||||
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--text2)', fontWeight: 500 }}>IP 주소</th>
|
||||
<th style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--text2)', fontWeight: 500 }}>24시간 내 플래시 횟수</th>
|
||||
<th style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--text2)', fontWeight: 500 }}>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{m.topIps.map((r, i) => (
|
||||
<tr key={r.ip} style={{ borderBottom: '1px solid var(--border)', background: i % 2 === 1 ? 'var(--bg2)' : undefined }}>
|
||||
<td style={{ padding: '9px 14px', fontFamily: 'monospace' }}>{r.ip}</td>
|
||||
<td style={{ padding: '9px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontWeight: 600 }}>{r.count}</span>
|
||||
<div style={{ flex: 1, background: 'var(--bg3)', borderRadius: 3, height: 6, maxWidth: 120 }}>
|
||||
<div style={{
|
||||
width: `${Math.min(100, (r.count / (m.topIps[0]?.count || 1)) * 100)}%`,
|
||||
height: '100%', background: r.count >= 10 ? 'var(--danger)' : r.count >= 5 ? 'var(--warn)' : 'var(--accent)',
|
||||
borderRadius: 3,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '9px 14px', fontSize: 12 }}>
|
||||
{r.count >= 10 ? <span style={{ color: 'var(--danger)' }}>고위험</span>
|
||||
: r.count >= 5 ? <span style={{ color: 'var(--warn)' }}>주의</span>
|
||||
: <span style={{ color: 'var(--success)' }}>정상</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{m.topIps.length === 0 && (
|
||||
<tr><td colSpan={3} style={{ textAlign: 'center', padding: 40, color: 'var(--text2)' }}>데이터 없음</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이상 감지 */}
|
||||
{tab === 'anomalies' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={showResolved} onChange={e => setShowResolved(e.target.checked)} />
|
||||
해소된 항목 포함
|
||||
</label>
|
||||
</div>
|
||||
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
|
||||
{['유형', '심각도', '설명', 'IP', 'MAC', '발생', '조치'].map(h => (
|
||||
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--text2)', fontWeight: 500 }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{anomalies.map((a, i) => (
|
||||
<tr key={a.id} style={{
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: a.resolved ? 'var(--bg2)' : i % 2 === 1 ? 'var(--bg2)' : undefined,
|
||||
opacity: a.resolved ? 0.6 : 1,
|
||||
}}>
|
||||
<td style={{ padding: '9px 14px', fontWeight: 500 }}>
|
||||
{ANOMALY_LABELS[a.type] || a.type}
|
||||
</td>
|
||||
<td style={{ padding: '9px 14px' }}>
|
||||
<span style={{
|
||||
color: SEVERITY_COLOR[a.severity] || 'var(--text2)',
|
||||
fontWeight: 600, fontSize: 12,
|
||||
}}>
|
||||
{a.severity === 'high' ? '높음' : a.severity === 'medium' ? '중간' : '낮음'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '9px 14px', maxWidth: 240 }}>{a.description}</td>
|
||||
<td style={{ padding: '9px 14px', fontFamily: 'monospace', fontSize: 12 }}>{a.clientIp || '—'}</td>
|
||||
<td style={{ padding: '9px 14px', fontFamily: 'monospace', fontSize: 12 }}>{a.macAddress || '—'}</td>
|
||||
<td style={{ padding: '9px 14px', fontSize: 12, color: 'var(--text2)', whiteSpace: 'nowrap' }}>
|
||||
{new Date(a.createdAt).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
<td style={{ padding: '9px 14px' }}>
|
||||
{a.resolved ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--success)' }}>해소됨</span>
|
||||
) : (
|
||||
<button className="btn btn-outline btn-sm" style={{ fontSize: 11, padding: '3px 10px' }}
|
||||
onClick={() => resolveAnomaly(a.id)}>
|
||||
해소
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{anomalies.length === 0 && (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 40, color: 'var(--text2)' }}>
|
||||
{showResolved ? '이상 이벤트 없음' : '미해소 이상 없음'}
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 => (
|
||||
<Link key={m.to} to={m.to} style={{ textDecoration: 'none' }}>
|
||||
<div className="card" style={{ cursor: 'pointer', transition: 'border-color .2s' }}
|
||||
|
||||
@@ -1,183 +1,469 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ESPLoader, Transport } from 'esptool-js';
|
||||
import api from '../api/client';
|
||||
|
||||
const CHIP_LABELS = {
|
||||
'ESP32-S3': 'ESP32-S3', 'ESP32-S2': 'ESP32-S2', 'ESP32-C3': 'ESP32-C3',
|
||||
'ESP32-C6': 'ESP32-C6', 'ESP32-H2': 'ESP32-H2', 'ESP32': 'ESP32',
|
||||
};
|
||||
const BAUD = 115200;
|
||||
|
||||
export default function Flash() {
|
||||
const { token } = useParams();
|
||||
const [info, setInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [flashDone, setFlashDone] = useState(false);
|
||||
const installRef = useRef(null);
|
||||
|
||||
const manifestUrl = `${window.location.origin}/api/flash/${token}/manifest`;
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/flash/${token}`)
|
||||
.then(r => setInfo(r.data))
|
||||
.catch(() => setInfo(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
// esp-web-tools 이벤트 — 플래시 완료/실패 시 서버에 기록
|
||||
useEffect(() => {
|
||||
const btn = installRef.current;
|
||||
if (!btn) return;
|
||||
|
||||
function onSuccess(e) {
|
||||
const mac = e.detail?.device?.macAddress || 'unknown';
|
||||
api.post(`/flash/${token}/consume`, {
|
||||
mac, chipFamily: info?.chipFamily, success: true,
|
||||
}).catch(() => {});
|
||||
setFlashDone(true);
|
||||
}
|
||||
function onFail(e) {
|
||||
api.post(`/flash/${token}/consume`, {
|
||||
mac: 'unknown', chipFamily: info?.chipFamily,
|
||||
success: false, errorMessage: e.detail?.message,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
btn.addEventListener('state-changed', (e) => {
|
||||
if (e.detail?.state === 'finished') onSuccess(e);
|
||||
if (e.detail?.state === 'error') onFail(e);
|
||||
});
|
||||
}, [info, token]);
|
||||
|
||||
if (loading) return <div className="spinner" />;
|
||||
|
||||
// 유효하지 않은 토큰
|
||||
if (!info) {
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 500 }}>
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>❌</div>
|
||||
<h2 style={{ marginBottom: 8 }}>유효하지 않은 토큰</h2>
|
||||
<p className="text-muted">토큰을 찾을 수 없습니다.</p>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
|
||||
구매 내역으로
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<div className="container page" style={{ maxWidth: 500 }}>
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>✅</div>
|
||||
<h2 style={{ marginBottom: 8 }}>이미 플래시됨</h2>
|
||||
<p className="text-muted" style={{ marginBottom: 8 }}>
|
||||
이 토큰은 {info.usedAt ? new Date(info.usedAt).toLocaleString('ko-KR') : ''}에 사용되었습니다.
|
||||
</p>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>1회용 토큰은 재사용할 수 없습니다.</p>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
|
||||
구매 내역으로
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function fmtMs(ms) {
|
||||
if (!ms) return '—';
|
||||
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
// 만료된 토큰
|
||||
if (info.expired) {
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 500 }}>
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⏰</div>
|
||||
<h2 style={{ marginBottom: 8 }}>만료된 토큰</h2>
|
||||
<p className="text-muted">토큰 유효기간이 지났습니다.</p>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>고객센터에 문의해주세요.</p>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
|
||||
구매 내역으로
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 펌웨어 없음
|
||||
if (!info.hasFirmware) {
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 500 }}>
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||||
<h2 style={{ marginBottom: 8 }}>펌웨어 파일 없음</h2>
|
||||
<p className="text-muted">판매자가 아직 펌웨어를 업로드하지 않았습니다.</p>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>판매자에게 문의하거나 환불을 요청하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="container page" style={{ maxWidth: 600 }}>
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 36 }}>⚡</div>
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 2 }}>{info.productName}</h2>
|
||||
<span className="text-muted" style={{ fontSize: 13 }}>
|
||||
{CHIP_LABELS[info.chipFamily] || info.chipFamily} · 만료: {new Date(info.expiresAt).toLocaleDateString('ko-KR')}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 24, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 12, left: '12%', right: '12%', height: 2, background: 'var(--border)', zIndex: 0 }} />
|
||||
{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 (
|
||||
<div key={sp} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', zIndex: 1, flex: 1 }}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', border: '2px solid',
|
||||
borderColor: done ? 'var(--success)' : active ? 'var(--accent)' : 'var(--border)',
|
||||
background: done ? 'var(--success)' : active ? 'var(--accent)' : 'var(--bg2)',
|
||||
color: done || active ? '#fff' : 'var(--text2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 13, fontWeight: 700, transition: 'all .3s',
|
||||
}}>
|
||||
{done ? '✓' : i + 1}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, marginTop: 5, color: active ? 'var(--accent)' : 'var(--text2)' }}>
|
||||
{STEP_LABELS[i]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{flashDone ? (
|
||||
<div className="alert alert-success" style={{ textAlign: 'center', padding: 24 }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>🎉</div>
|
||||
<strong>플래시 완료!</strong>
|
||||
<p style={{ marginTop: 8, fontSize: 13 }}>펌웨어가 성공적으로 ESP32에 기록되었습니다.</p>
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline btn-sm">구매 내역</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="alert alert-info" style={{ marginBottom: 20 }}>
|
||||
<strong>플래시 전 확인사항</strong>
|
||||
<ul style={{ marginTop: 8, paddingLeft: 16, fontSize: 13, lineHeight: 2 }}>
|
||||
<li>Chrome 또는 Edge 브라우저를 사용하고 있나요?</li>
|
||||
<li>ESP32를 USB 케이블로 PC에 연결했나요?</li>
|
||||
<li>이 토큰은 <strong>1회만</strong> 사용 가능합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, padding: '24px 0' }}>
|
||||
<esp-web-install-button
|
||||
ref={installRef}
|
||||
manifest={manifestUrl}
|
||||
style={{ '--esp-tools-button-color': '#6366f1', '--esp-tools-button-text-color': '#fff' }}
|
||||
>
|
||||
<button slot="activate" className="btn btn-primary"
|
||||
style={{ fontSize: 16, padding: '12px 32px' }}>
|
||||
⚡ ESP32 플래시 시작
|
||||
</button>
|
||||
<span slot="unsupported" style={{ color: 'var(--danger)', fontSize: 14 }}>
|
||||
Chrome 또는 Edge 브라우저가 필요합니다
|
||||
</span>
|
||||
</esp-web-install-button>
|
||||
<p className="text-muted" style={{ fontSize: 12, textAlign: 'center' }}>
|
||||
버튼 클릭 후 팝업에서 ESP32 포트를 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<details style={{ fontSize: 13, color: 'var(--text2)' }}>
|
||||
<summary style={{ cursor: 'pointer', marginBottom: 8 }}>토큰 정보 (고급)</summary>
|
||||
<code style={{ wordBreak: 'break-all', display: 'block', background: 'var(--bg3)', padding: 8, borderRadius: 4 }}>
|
||||
{token}
|
||||
</code>
|
||||
<p style={{ marginTop: 8 }}>매니페스트 URL: <code style={{ fontSize: 11 }}>{manifestUrl}</code></p>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
function StatusCard({ icon, title, desc, children }) {
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 480 }}>
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>{icon}</div>
|
||||
<h2 style={{ marginBottom: 8 }}>{title}</h2>
|
||||
<p className="text-muted" style={{ marginBottom: 20 }}>{desc}</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, mono }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ color: 'var(--text2)', fontSize: 11, marginBottom: 2 }}>{label}</div>
|
||||
<div style={{ fontFamily: mono ? 'monospace' : undefined, fontSize: 13 }}>{value || '—'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <div className="spinner" />;
|
||||
if (!info) {
|
||||
return (
|
||||
<StatusCard icon="❌" title="유효하지 않은 토큰" desc="토큰을 찾을 수 없습니다.">
|
||||
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
|
||||
</StatusCard>
|
||||
);
|
||||
}
|
||||
if (info.expired) {
|
||||
return (
|
||||
<StatusCard icon="⏰" title="만료된 토큰"
|
||||
desc={`유효기간: ${new Date(info.expiresAt).toLocaleDateString('ko-KR')}`}>
|
||||
{info.orderId && <button className="btn btn-primary" onClick={requestReflash}>새 토큰 발급 (무료)</button>}
|
||||
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
|
||||
</StatusCard>
|
||||
);
|
||||
}
|
||||
if (info.isUsed && phase !== 'success') {
|
||||
return (
|
||||
<StatusCard icon="✅" title="이미 플래시됨"
|
||||
desc={`${new Date(info.usedAt).toLocaleString('ko-KR')} 완료`}>
|
||||
{info.orderId && <button className="btn btn-primary" onClick={requestReflash}>업데이트 토큰 발급</button>}
|
||||
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
|
||||
</StatusCard>
|
||||
);
|
||||
}
|
||||
if (info.isLocked && phase !== 'locked') {
|
||||
return (
|
||||
<StatusCard icon="🔒" title="토큰 잠김"
|
||||
desc={info.lockedReason || '너무 많은 실패로 잠겼습니다. 관리자에게 문의하세요.'}>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
|
||||
</StatusCard>
|
||||
);
|
||||
}
|
||||
if (!info.hasFirmware) {
|
||||
return (
|
||||
<StatusCard icon="⚠️" title="펌웨어 없음" desc="판매자가 아직 펌웨어를 업로드하지 않았습니다." >
|
||||
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
|
||||
</StatusCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container page" style={{ maxWidth: 680 }}>
|
||||
<div className="card">
|
||||
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 36 }}>⚡</div>
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 2 }}>{info.productName}</h2>
|
||||
<span className="text-muted" style={{ fontSize: 13 }}>
|
||||
{info.chipFamily} · 만료: {new Date(info.expiresAt).toLocaleDateString('ko-KR')}
|
||||
{info.isUpdateToken && (
|
||||
<span style={{ marginLeft: 8, background: 'var(--accent)', color: '#fff',
|
||||
padding: '1px 7px', borderRadius: 10, fontSize: 11 }}>업데이트</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepBar phase={phase} />
|
||||
|
||||
{/* 남은 시도 횟수 경고 */}
|
||||
{remaining !== null && remaining < info.maxAttempts && phase !== 'success' && phase !== 'locked' && (
|
||||
<div style={{
|
||||
background: remaining <= 1 ? 'rgba(239,68,68,.12)' : 'rgba(251,191,36,.12)',
|
||||
border: `1px solid ${remaining <= 1 ? 'var(--danger)' : 'var(--warn)'}`,
|
||||
borderRadius: 6, padding: '8px 14px', marginBottom: 16, fontSize: 13,
|
||||
}}>
|
||||
남은 시도: <strong>{remaining}</strong>/{info.maxAttempts}회
|
||||
{remaining <= 1 && ' — 마지막 기회입니다!'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 보드 정보 카드 */}
|
||||
{boardInfo && phase !== 'success' && (
|
||||
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 8,
|
||||
padding: '14px 18px', marginBottom: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 10, color: 'var(--accent)' }}>
|
||||
보드 정보 (esptool 읽기)
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<InfoRow label="MAC 주소" value={boardInfo.mac} mono />
|
||||
<InfoRow label="칩 종류" value={boardInfo.chipName} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 진행 바 */}
|
||||
{(phase === 'downloading' || phase === 'flashing') && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, marginBottom: 6 }}>
|
||||
<span className="text-muted">{phase === 'downloading' ? '다운로드' : '기록'}: {curFile}</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div style={{ background: 'var(--bg3)', borderRadius: 4, height: 8, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${progress}%`, height: '100%', background: 'var(--accent)', transition: 'width .2s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 */}
|
||||
{error && (
|
||||
<div style={{ background: 'rgba(239,68,68,.1)', border: '1px solid var(--danger)',
|
||||
borderRadius: 6, padding: '10px 14px', marginBottom: 16, fontSize: 13, whiteSpace: 'pre-wrap' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 성공 */}
|
||||
{phase === 'success' && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<div style={{ fontSize: 40, marginBottom: 8 }}>🎉</div>
|
||||
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 6 }}>플래시 완료!</div>
|
||||
{boardInfo && <p style={{ fontSize: 13, color: 'var(--text2)' }}>MAC: {boardInfo.mac} · 칩: {boardInfo.chipName}</p>}
|
||||
{duration && <p style={{ fontSize: 13, color: 'var(--text2)' }}>소요시간: {fmtMs(duration)}</p>}
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={requestReflash}>업데이트 토큰 발급</button>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline btn-sm">구매 내역</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 잠금 */}
|
||||
{phase === 'locked' && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<div style={{ fontSize: 40, marginBottom: 8 }}>🔒</div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>토큰이 잠겼습니다</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)' }}>최대 시도 횟수를 초과했습니다.</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)' }}>관리자에게 문의하시면 잠금 해제가 가능합니다.</p>
|
||||
<Link to="/dashboard/orders" className="btn btn-outline btn-sm" style={{ marginTop: 12, display: 'inline-block' }}>구매 내역으로</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션: 초기 상태 */}
|
||||
{phase === 'idle' && (
|
||||
<>
|
||||
<div style={{ background: 'rgba(99,102,241,.08)', border: '1px solid rgba(99,102,241,.3)',
|
||||
borderRadius: 6, padding: '12px 16px', marginBottom: 20, fontSize: 13 }}>
|
||||
<strong>플래시 전 확인사항</strong>
|
||||
<ul style={{ marginTop: 8, paddingLeft: 16, lineHeight: 2 }}>
|
||||
<li>Chrome 또는 Edge 브라우저 사용 중인가요?</li>
|
||||
<li>ESP32가 USB로 PC에 연결되어 있나요?</li>
|
||||
<li>HTTPS 또는 localhost 접속인가요? <span style={{ color: 'var(--warn)' }}>(Web Serial 필수)</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
|
||||
<button className="btn btn-primary" style={{ fontSize: 15, padding: '12px 32px' }} onClick={connectAndRead}>
|
||||
🔌 ESP32 연결 및 보드 정보 읽기
|
||||
</button>
|
||||
<p className="text-muted" style={{ fontSize: 12 }}>버튼 클릭 → 팝업에서 포트 선택</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 연결/읽기 중 */}
|
||||
{(phase === 'connecting' || phase === 'reading') && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<div className="spinner" style={{ margin: '0 auto 12px' }} />
|
||||
<p className="text-muted" style={{ fontSize: 14 }}>
|
||||
{phase === 'connecting' ? 'ESP32 연결 중…' : '보드 정보 읽는 중…'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 보드 확인 완료 → 플래시 버튼 */}
|
||||
{phase === 'board_ready' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, paddingTop: 8 }}>
|
||||
<button className="btn btn-primary" style={{ fontSize: 15, padding: '12px 32px' }} onClick={startFlash}>
|
||||
⚡ 펌웨어 플래시 시작
|
||||
</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={retry}>다른 보드 연결</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 다운로드/플래시 중 */}
|
||||
{(phase === 'downloading' || phase === 'flashing') && (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>
|
||||
{phase === 'downloading' ? '펌웨어 다운로드 중…' : '플래시 기록 중… USB 케이블을 뽑지 마세요'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 실패 → 재시도 */}
|
||||
{phase === 'failed' && (
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', flexWrap: 'wrap', paddingTop: 8 }}>
|
||||
{remaining > 0 && (
|
||||
<button className="btn btn-primary" onClick={retry}>재시도 ({remaining}회 남음)</button>
|
||||
)}
|
||||
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ESP 디버그 로그 */}
|
||||
{logs.length > 0 && (
|
||||
<details style={{ marginTop: 20 }}>
|
||||
<summary style={{ cursor: 'pointer', fontSize: 12, color: 'var(--text2)' }}>
|
||||
ESP 로그 ({logs.length}줄)
|
||||
</summary>
|
||||
<div style={{
|
||||
marginTop: 8, background: 'var(--bg3)', padding: 10, borderRadius: 4,
|
||||
maxHeight: 160, overflowY: 'auto', fontFamily: 'monospace',
|
||||
fontSize: 11, whiteSpace: 'pre-wrap', color: 'var(--text2)',
|
||||
}}>
|
||||
{logs.join('\n')}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', marginTop: 20, paddingTop: 14 }}>
|
||||
<details style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||
<summary style={{ cursor: 'pointer' }}>토큰 정보</summary>
|
||||
<code style={{ wordBreak: 'break-all', display: 'block', background: 'var(--bg3)',
|
||||
padding: 8, borderRadius: 4, marginTop: 6 }}>{token}</code>
|
||||
<p style={{ marginTop: 6 }}>펌웨어: {info.firmwareFiles?.length}개 파일</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user