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 } },
|
||||
|
||||
Reference in New Issue
Block a user