diff --git a/backend/src/db.js b/backend/src/db.js index a457d4d..1248d4a 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1,3 +1,5 @@ +const fs = require('fs/promises') +const path = require('path') const mysql = require('mysql2/promise') const DB_HOST = process.env.DB_HOST || '127.0.0.1' @@ -36,6 +38,21 @@ function collectUploadSrcsFromItems(items, bucket) { } } +function resolveMonthRange(month) { + if (typeof month !== 'string') return null + const match = month.trim().match(/^(\d{4})-(\d{2})$/) + if (!match) return null + + const year = Number(match[1]) + const monthIndex = Number(match[2]) - 1 + if (!Number.isInteger(year) || monthIndex < 0 || monthIndex > 11) return null + + return { + start: new Date(year, monthIndex, 1).getTime(), + end: new Date(year, monthIndex + 1, 1).getTime(), + } +} + function mapUserRow(row) { if (!row) return null return { @@ -645,10 +662,24 @@ async function updateImageOptimizationJobStatus({ id, status, optimizedByteSize return findImageOptimizationJobById(id) } -async function listRecentImageOptimizationJobs(limit = 20) { +async function listRecentImageOptimizationJobs(limit = 20, { month } = {}) { const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20)) + const range = resolveMonthRange(month) + const where = [] + const params = [] + + if (range) { + where.push('queued_at >= ? AND queued_at < ?') + params.push(range.start, range.end) + } + const rows = await query( - `SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs ORDER BY queued_at DESC LIMIT ${safeLimit}` + `SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at + FROM image_optimization_jobs + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY queued_at DESC + LIMIT ${safeLimit}`, + params ) return rows.map(mapImageOptimizationJobRow) } @@ -706,8 +737,94 @@ async function deleteImageAssets(ids) { return rows.map(mapImageAssetRow) } -async function getImageAssetStats() { - const [assetRows, jobRows] = await Promise.all([ +async function listReferencedUploadSources() { + const referencedSrcs = new Set() + const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ + query("SELECT avatar_src FROM users WHERE avatar_src <> ''"), + query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"), + query("SELECT src FROM game_items WHERE src <> ''"), + query("SELECT src FROM custom_items WHERE src <> ''"), + query("SELECT thumbnail_src, pool_json FROM tierlists"), + query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"), + ]) + + for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src) + for (const row of gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src) + for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src) + for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src) + + for (const row of tierListRows) { + if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src) + collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs) + } + + for (const row of templateRequestRows) { + if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot) + collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs) + } + + return Array.from(referencedSrcs) +} + +async function listImageAssets() { + const rows = await query( + 'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC' + ) + return rows.map(mapImageAssetRow) +} + +async function getReferencedUploadFootprint() { + const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()]) + const assetMap = new Map(assets.map((asset) => [asset.src, asset])) + let totalReferencedByteSize = 0 + let trackedReferencedByteSize = 0 + let legacyReferencedByteSize = 0 + let trackedReferencedCount = 0 + let legacyReferencedCount = 0 + let missingCount = 0 + + for (const src of referencedSrcs) { + if (typeof src !== 'string' || !src.startsWith('/uploads/')) continue + const absolutePath = path.join(__dirname, '..', src.replace(/^\//, '')) + + try { + const stat = await fs.stat(absolutePath) + const size = Number(stat.size || 0) + totalReferencedByteSize += size + if (assetMap.has(src)) { + trackedReferencedCount += 1 + trackedReferencedByteSize += size + } else { + legacyReferencedCount += 1 + legacyReferencedByteSize += size + } + } catch (error) { + if (error?.code === 'ENOENT') missingCount += 1 + } + } + + return { + referencedCount: referencedSrcs.length, + totalReferencedByteSize, + trackedReferencedCount, + trackedReferencedByteSize, + legacyReferencedCount, + legacyReferencedByteSize, + missingCount, + } +} + +async function getImageAssetStats({ month } = {}) { + const range = resolveMonthRange(month) + const jobWhere = [] + const jobParams = [] + + if (range) { + jobWhere.push('queued_at >= ? AND queued_at < ?') + jobParams.push(range.start, range.end) + } + + const [assetRows, jobRows, footprint] = await Promise.all([ query( `SELECT COUNT(*) AS asset_count, COALESCE(SUM(byte_size), 0) AS total_byte_size, COALESCE(SUM(original_byte_size), 0) AS total_original_byte_size FROM image_assets` ), @@ -718,8 +835,11 @@ async function getImageAssetStats() { COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed_count, COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count, COALESCE(SUM(CASE WHEN status = 'completed' AND reused_asset = 1 THEN 1 ELSE 0 END), 0) AS reused_count - FROM image_optimization_jobs` + FROM image_optimization_jobs + ${jobWhere.length ? `WHERE ${jobWhere.join(' AND ')}` : ''}`, + jobParams ), + getReferencedUploadFootprint(), ]) const asset = assetRows[0] || {} @@ -734,6 +854,13 @@ async function getImageAssetStats() { totalOriginalByteSize, savedByteSize, savingsRatio: totalOriginalByteSize > 0 ? savedByteSize / totalOriginalByteSize : 0, + referencedCount: Number(footprint.referencedCount || 0), + referencedByteSize: Number(footprint.totalReferencedByteSize || 0), + trackedReferencedCount: Number(footprint.trackedReferencedCount || 0), + trackedReferencedByteSize: Number(footprint.trackedReferencedByteSize || 0), + legacyReferencedCount: Number(footprint.legacyReferencedCount || 0), + legacyReferencedByteSize: Number(footprint.legacyReferencedByteSize || 0), + missingReferencedCount: Number(footprint.missingCount || 0), queuedCount: Number(jobs.queued_count || 0), processingCount: Number(jobs.processing_count || 0), completedCount: Number(jobs.completed_count || 0), @@ -741,6 +868,17 @@ async function getImageAssetStats() { reusedCount: Number(jobs.reused_count || 0), } } + +async function clearImageOptimizationJobs({ month } = {}) { + const range = resolveMonthRange(month) + if (range) { + const result = await query('DELETE FROM image_optimization_jobs WHERE queued_at >= ? AND queued_at < ?', [range.start, range.end]) + return Number(result.affectedRows || 0) + } + + const result = await query('DELETE FROM image_optimization_jobs') + return Number(result.affectedRows || 0) +} async function createGameItem({ id, gameId, src, label }) { const createdAt = now() await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ @@ -1631,6 +1769,8 @@ module.exports = { listRecentImageOptimizationJobs, listUnusedImageAssets, deleteImageAssets, + listReferencedUploadSources, + clearImageOptimizationJobs, getImageAssetStats, createGameItem, updateGameItemLabel, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 0f62a96..f3db3c3 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -34,6 +34,7 @@ const { deleteImageAssets, getImageAssetStats, listRecentImageOptimizationJobs, + clearImageOptimizationJobs, } = require('../db') const { requireAdmin } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage') @@ -251,17 +252,37 @@ router.post('/image-assets/cleanup', requireAdmin, async (req, res) => { }) router.get('/image-assets/stats', requireAdmin, async (req, res) => { + const schema = z.object({ + month: z.string().regex(/^\d{4}-\d{2}$/).optional(), + limit: z.coerce.number().int().min(1).max(24).optional().default(12), + }) + const parsed = schema.safeParse(req.query) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const filters = { month: parsed.data.month } const [stats, recentJobs] = await Promise.all([ - getImageAssetStats(), - listRecentImageOptimizationJobs(6), + getImageAssetStats(filters), + listRecentImageOptimizationJobs(parsed.data.limit, filters), ]) res.json({ stats, + filters, queue: getImageOptimizationQueueState(), recentJobs, }) }) +router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => { + const schema = z.object({ + month: z.string().regex(/^\d{4}-\d{2}$/).optional().nullable(), + }) + const parsed = schema.safeParse(req.body || {}) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const deletedCount = await clearImageOptimizationJobs({ month: parsed.data.month || undefined }) + res.json({ deletedCount }) +}) + async function removeCustomItemFiles(items) { await Promise.all( items.map(async (item) => { diff --git a/docs/todo.md b/docs/todo.md index 311c03b..2f47970 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,40 +1,11 @@ # 할 일 및 이슈 ## 즉시 확인 필요 -- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다. -- 홈/게임 허브/내 티어표/즐겨찾기 카드 문법은 어느 정도 통일됐지만, 아직 실제 SVG 아이콘, 미세 간격, hover/selection 상태 같은 디테일은 더 다듬을 필요가 있다. -- 목록 화면 상단 도구 막대는 공통 카드 문법으로 거의 맞췄지만, 실제 피그마처럼 필터 토글/정렬 상태를 시각적으로 더 강하게 드러내는 디테일은 남아 있다. -- 현재 공통 셸에는 임시 선형 SVG 아이콘을 사용하므로, 최종 머티리얼 아이콘 에셋을 받으면 교체하고 아이콘 크기/정렬을 다시 미세 조정할 필요가 있다. -- 공통 셸과 에디터에는 일부 실제 SVG 아이콘을 연결했지만, 아직 즐겨찾기/설정/관리자 등 나머지 내비 아이콘은 임시 선형 SVG이므로 추가 에셋 교체가 남아 있다. -- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다. -- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다. -- 에디터/관리자 로컬 우측 패널은 셸 카드에서 분리됐지만, 아직 실제 피그마처럼 패널 토글 전환 모션과 상태 강조가 더 필요하다. -- 에디터 로컬 우측 패널은 공통 토글과 연결됐지만, 아직 완전한 피그마 수준의 패널 애니메이션과 내부 카드 재배치는 더 다듬을 필요가 있다. -- 에디터 우측 패널은 셸의 세 번째 컬럼으로 옮겼지만, 내부 카드 간격과 섹션 구분선은 아직 첨부 시안처럼 더 촘촘하게 정리할 필요가 있다. -- 에디터 우측 패널 외곽 래퍼는 제거했으므로, 다음 단계는 공통 오른쪽 컬럼 안에서 입력/버튼/구분선 간격을 시안처럼 더 정교하게 다듬는 작업이다. -- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다. -- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다. -- 좌측 레일 축소형은 반영했으므로, 다음 단계는 축소 상태에서 관리자/로그인 진입점과 hover 툴팁 같은 보조 UX를 더 다듬는 작업이다. -- 좌우 하단 액션 영역은 분리했으므로, 다음 단계는 축소된 왼쪽 레일에서도 관리자/로그인 버튼을 아이콘형으로 어떻게 유지할지 검토할 수 있다. -- 홈 게임 카드 메타는 간소화했으므로, 이후 필요하면 게임 썸네일은 상세 허브나 우측 패널처럼 더 맥락이 분명한 위치에만 쓰는 방향을 검토할 수 있다. -- 좌우 하단 액션은 항상 보이도록 보정했으므로, 다음 단계는 축소된 레일 상태에서 액션 버튼의 아이콘화 여부를 추가 검토할 수 있다. -- 카드 목록은 4열 기준과 메타 줄 구성까지 통일했으므로, 다음 단계는 필터 상태 배지나 hover·selection 강조 같은 상호작용 디테일을 더 다듬는 작업이다. -- 검색 결과 화면은 좌측 전역 검색 입력만 쓰도록 정리됐으므로, 다음 단계는 결과 필터/정렬 여부를 검토하는 식으로 확장하면 된다. -- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다. -- 홈 화면 우측 사이드는 CTA 하나만 남긴 상태이므로, 이후 필요할 때도 임시 정보 카드 다수를 다시 넣기보다 실제 필요한 기능만 선별해 추가해야 한다. -- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다. -- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다. -- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다. -- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다. +- 레거시 업로드 파일도 현재 실사용 용량에는 포함되지만, 과거 자산까지 'image_assets' 메타에 백필한 상태는 아니므로 필요하면 1회 백필 스크립트를 추가해 절감률/중복 통계를 완전히 일원화한다. - 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. - 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. -- 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다. -- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다. - 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다. -- 즐겨찾기는 현재 `내 즐겨찾기` 목록과 정렬까지 지원하므로, 필요하면 폴더 분류나 메모 같은 개인 정리 기능을 추가 검토한다. -- 전역 토스트는 중복 합치기와 페이드아웃까지 지원하므로, 필요하면 액션 링크나 수동 고정(pin) 같은 상호작용 확장을 검토한다. -- 공개 티어표 검색은 현재 게임별 허브 안에서만 제공하므로, 필요하면 홈 전역 통합 검색도 검토한다. -- 즐겨찾기 토글은 현재 상세 화면 중심이므로, 필요하면 카드 목록에서도 안전한 보조 인터랙션(예: 길게 누르기, 별도 메뉴)을 검토한다. +- 이미지 최적화 기록은 월별 조회/비우기까지 지원하므로, 운영 단계에서는 보관 기간 정책과 자동 아카이브 기준을 정한다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. @@ -44,8 +15,6 @@ - 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다. ## 중기 개선 -- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다. -- 자동 테스트와 최소한의 배포 체크리스트를 만든다. - 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다. - 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다. - 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다. diff --git a/docs/update.md b/docs/update.md index 9c86d6f..25a4809 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-31 v1.3.5 +- 관리자 이미지 최적화 대시보드는 이제 'image_assets'만이 아니라 현재 실제로 참조 중인 업로드 파일 전체를 합산해, 기존 레거시 업로드까지 포함한 실사용 용량을 함께 보여주도록 확장함. +- 최근 최적화 작업은 기본 12건으로 늘리고 6/12/24건 선택과 월 단위 필터를 지원해, 특정 기간 사용량과 최적화 이력을 운영 관점에서 바로 확인할 수 있게 정리함. +- 관리자에서 월별 또는 전체 최적화 기록을 비우는 정리 액션을 추가하고, todo 문서도 현재 이미지 최적화 흐름에 맞게 갱신함. + ## 2026-03-31 v1.3.4 - 관리자 API에 이미지 자산 통계 엔드포인트를 추가해 총 자산 수, 현재 용량, 원본 대비 절감 용량/절감률, 작업 누적 상태를 조회할 수 있게 확장함. - 관리자 오른쪽 사이드 하단에 `Image Optimization` 패널을 추가해 큐 상태, 절감 통계, 최근 최적화 작업을 바로 확인할 수 있도록 대시보드를 구성함. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index f12c642..d03fb6a 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -44,7 +44,13 @@ export const api = { listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) => request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`), listAdminTemplateRequests: () => request('/api/admin/template-requests'), - getAdminImageAssetStats: () => request('/api/admin/image-assets/stats'), + getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => { + const query = new URLSearchParams() + if (month) query.set('month', month) + query.set('limit', String(limit)) + return request(`/api/admin/image-assets/stats?${query.toString()}`) + }, + resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }), listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`), cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }), promoteAdminCustomItem: (itemId, payload) => diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 9192634..17d0eae 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -60,6 +60,9 @@ const users = ref([]) const imageStats = ref(null) const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 }) const imageRecentJobs = ref([]) +const imageStatsMonth = ref('') +const imageStatsLimit = ref(12) +const imageResetModalOpen = ref(false) const error = ref('') const success = ref('') @@ -246,16 +249,23 @@ const imageDiagnosticsCards = computed(() => { const stats = imageStats.value if (!stats) return [] return [ - { label: '총 자산', value: `${stats.assetCount || 0}` }, - { label: '현재 용량', value: formatBytes(stats.totalByteSize) }, + { label: '실사용 파일', value: `${stats.referencedCount || 0}` }, + { label: '현재 용량', value: formatBytes(stats.referencedByteSize) }, + { label: '추적 자산', value: `${stats.trackedReferencedCount || 0}` }, + { label: '레거시 참조', value: `${stats.legacyReferencedCount || 0}` }, { label: '절감 용량', value: formatBytes(stats.savedByteSize) }, { label: '절감률', value: `${Math.round((stats.savingsRatio || 0) * 100)}%` }, ] }) +const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간')) + async function refreshImageDiagnostics() { try { - const data = await api.getAdminImageAssetStats() + const data = await api.getAdminImageAssetStats({ + month: imageStatsMonth.value || '', + limit: imageStatsLimit.value, + }) imageStats.value = data.stats || null imageQueue.value = data.queue || { concurrency: 1, activeCount: 0, pendingCount: 0 } imageRecentJobs.value = data.recentJobs || [] @@ -264,6 +274,27 @@ async function refreshImageDiagnostics() { } } +function openImageResetModal() { + imageResetModalOpen.value = true +} + +function closeImageResetModal() { + imageResetModalOpen.value = false +} + +async function confirmImageReset() { + try { + const data = await api.resetAdminImageAssetStats({ month: imageStatsMonth.value || null }) + success.value = imageStatsMonth.value + ? `${imageStatsMonth.value} 기록 ${data.deletedCount || 0}건을 정리했어요.` + : `전체 최적화 기록 ${data.deletedCount || 0}건을 정리했어요.` + closeImageResetModal() + await refreshImageDiagnostics() + } catch (e) { + error.value = '이미지 최적화 기록을 정리하지 못했어요.' + } +} + function setTab(tab) { resetMessages() activeTab.value = tab @@ -1733,6 +1764,19 @@ async function saveFeaturedOrder() { +