diff --git a/backend/src/db.js b/backend/src/db.js index c01237d..3b209ff 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -28,6 +28,14 @@ function serializeJson(value) { return JSON.stringify(value || []) } +function collectUploadSrcsFromItems(items, bucket) { + for (const item of items || []) { + if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) { + bucket.add(item.src) + } + } +} + function mapUserRow(row) { if (!row) return null return { @@ -644,6 +652,59 @@ async function listRecentImageOptimizationJobs(limit = 20) { ) return rows.map(mapImageOptimizationJobRow) } + +async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) { + const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100)) + const safeMinAgeHours = Math.max(0, Number(minAgeHours) || 24) + const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000 + + const assets = (await query( + `SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`, + [cutoff] + )).map(mapImageAssetRow) + + if (!assets.length) return [] + + 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 assets.filter((asset) => !referencedSrcs.has(asset.src)) +} + +async function deleteImageAssets(ids) { + const uniqueIds = Array.from(new Set((ids || []).filter(Boolean))) + if (!uniqueIds.length) return [] + const placeholders = uniqueIds.map(() => '?').join(', ') + const rows = await query( + `SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`, + uniqueIds + ) + await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds) + return rows.map(mapImageAssetRow) +} async function createGameItem({ id, gameId, src, label }) { const createdAt = now() await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ @@ -1532,6 +1593,8 @@ module.exports = { findImageOptimizationJobById, updateImageOptimizationJobStatus, listRecentImageOptimizationJobs, + listUnusedImageAssets, + deleteImageAssets, createGameItem, updateGameItemLabel, deleteGameItem, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index b2bbf5b..5a73c3b 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -30,6 +30,8 @@ const { adminUpdateUser, adminUpdateUserPassword, adminDeleteUser, + listUnusedImageAssets, + deleteImageAssets, } = require('../db') const { requireAdmin } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') @@ -206,6 +208,46 @@ router.get('/template-requests', requireAdmin, async (req, res) => { res.json({ requests }) }) +router.get('/image-assets/orphans', requireAdmin, async (req, res) => { + const schema = z.object({ + limit: z.coerce.number().int().min(1).max(500).optional().default(100), + minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24), + }) + const parsed = schema.safeParse(req.query) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const assets = await listUnusedImageAssets(parsed.data) + res.json({ assets }) +}) + +async function removeImageAssetFiles(assets) { + await Promise.all( + (assets || []).map(async (asset) => { + if (!asset?.src || !asset.src.startsWith('/uploads/')) return + const absolutePath = path.join(__dirname, '..', '..', asset.src.replace(/^\//, '')) + try { + await fs.unlink(absolutePath) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + }) + ) +} + +router.post('/image-assets/cleanup', requireAdmin, async (req, res) => { + const schema = z.object({ + limit: z.coerce.number().int().min(1).max(500).optional().default(100), + minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24), + }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const assets = await listUnusedImageAssets(parsed.data) + const deleted = await deleteImageAssets(assets.map((asset) => asset.id)) + await removeImageAssetFiles(deleted) + res.json({ deletedCount: deleted.length, assets: deleted }) +}) + async function removeCustomItemFiles(items) { await Promise.all( items.map(async (item) => { @@ -221,33 +263,18 @@ async function removeCustomItemFiles(items) { } async function promoteCustomItemToGameItem({ customItem, gameId }) { - const originalName = path.basename(customItem.src || '') - const nextFilename = buildUploadFilename({ originalname: originalName }) - const sourcePath = path.join(__dirname, '..', '..', customItem.src.replace(/^\//, '')) - const targetRelativePath = path.join('uploads', 'games', nextFilename) - const targetPath = path.join(__dirname, '..', '..', targetRelativePath) - - await fs.copyFile(sourcePath, targetPath) - return createGameItem({ id: nanoid(), gameId, - src: `/${targetRelativePath.replace(/\\/g, '/')}`, + src: customItem.src || '', label: customItem.label, }) } async function copyUploadIntoGameAsset(src) { if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || '' - - const originalName = path.basename(src) - const nextFilename = buildUploadFilename({ originalname: originalName }) - const sourcePath = path.join(__dirname, '..', '..', src.replace(/^\//, '')) - const targetRelativePath = path.join('uploads', 'games', nextFilename) - const targetPath = path.join(__dirname, '..', '..', targetRelativePath) - - await fs.copyFile(sourcePath, targetPath) - return `/${targetRelativePath.replace(/\\/g, '/')}` + if (src.startsWith('/uploads/assets/')) return src + return src } function uniqueTierListPoolItems(tierList) { diff --git a/docs/update.md b/docs/update.md index b3a5397..a75cfa4 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-31 v1.3.3 +- `image_assets` 참조를 전수 점검해 아무 곳에서도 사용하지 않는 최적화 이미지 자산만 추려내는 정리 배치 로직을 추가함. +- 관리자용 미사용 자산 조회/정리 API를 추가해 오래된 고아 이미지 자산을 미리 확인하거나 실제로 삭제할 수 있도록 확장함. +- 관리자 승격/템플릿 생성 과정은 기존 `/uploads/assets/` 자산을 그대로 재사용하도록 바꿔, 불필요한 복제 파일이 다시 생기지 않게 정리함. + ## 2026-03-31 v1.3.2 - 업로드 최적화는 이제 백엔드 내부 대기열을 통해 처리되어, 다수 이미지가 한 번에 들어와도 설정된 동시성 안에서 순차적으로 안정적으로 변환되도록 정리함. - `image_optimization_jobs` 작업 기록 테이블을 추가해 queued/processing/completed/failed 상태와 원본·최적화 용량, 재사용 여부, 시작/종료 시각을 저장하도록 확장함.