diff --git a/backend/src/db.js b/backend/src/db.js index 2818583..d4f3a22 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1042,6 +1042,156 @@ async function getReferencedUploadFootprint() { } } +async function fileExistsForUploadSrc(src) { + if (typeof src !== 'string' || !src.startsWith('/uploads/')) return true + const absolutePath = path.join(__dirname, '..', src.replace(/^\//, '')) + try { + await fs.stat(absolutePath) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +function stripItemIdsFromGroups(groups, missingItemIds) { + let changed = false + const nextGroups = (groups || []).map((group) => { + const nextItemIds = (group?.itemIds || []).filter((itemId) => !missingItemIds.has(itemId)) + if (nextItemIds.length !== (group?.itemIds || []).length) changed = true + return { + ...group, + itemIds: nextItemIds, + } + }) + return { changed, groups: nextGroups } +} + +function stripMissingItems(items, missingItemIds, missingSrcs) { + let changed = false + const nextItems = (items || []).filter((item) => { + const shouldRemove = + (item?.id && missingItemIds.has(item.id)) || + (typeof item?.src === 'string' && missingSrcs.has(item.src)) + if (shouldRemove) changed = true + return !shouldRemove + }) + return { changed, items: nextItems } +} + +async function cleanupMissingUploadReferences() { + const stats = { + clearedAvatars: 0, + clearedGameThumbnails: 0, + deletedGameItems: 0, + updatedTierLists: 0, + updatedTemplateRequests: 0, + deletedCustomItems: 0, + } + + const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([ + query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"), + query("SELECT id, thumbnail_src FROM games WHERE thumbnail_src <> ''"), + query("SELECT id, src FROM game_items WHERE src <> ''"), + query("SELECT id, src FROM custom_items WHERE src <> ''"), + query("SELECT id, thumbnail_src, groups_json, pool_json FROM tierlists"), + query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"), + ]) + + for (const row of userRows) { + if (await fileExistsForUploadSrc(row.avatar_src)) continue + await query('UPDATE users SET avatar_src = ? WHERE id = ?', ['', row.id]) + stats.clearedAvatars += 1 + } + + for (const row of gameRows) { + if (await fileExistsForUploadSrc(row.thumbnail_src)) continue + await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', ['', row.id]) + stats.clearedGameThumbnails += 1 + } + + for (const row of gameItemRows) { + if (await fileExistsForUploadSrc(row.src)) continue + await deleteGameItem(row.id) + stats.deletedGameItems += 1 + } + + const missingCustomItemIds = new Set() + const missingCustomSrcs = new Set() + for (const row of customItemRows) { + if (await fileExistsForUploadSrc(row.src)) continue + missingCustomItemIds.add(row.id) + missingCustomSrcs.add(row.src) + } + + if (missingCustomItemIds.size || missingCustomSrcs.size) { + for (const row of tierListRows) { + const groups = parseJson(row.groups_json, []) + const pool = parseJson(row.pool_json, []) + let changed = false + let nextThumbnail = row.thumbnail_src || '' + + if (row.thumbnail_src && !(await fileExistsForUploadSrc(row.thumbnail_src))) { + nextThumbnail = '' + changed = true + } + + const strippedPool = stripMissingItems(pool, missingCustomItemIds, missingCustomSrcs) + const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds) + if (strippedPool.changed || strippedGroups.changed) changed = true + + if (changed) { + await query('UPDATE tierlists SET thumbnail_src = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?', [ + nextThumbnail, + serializeJson(strippedGroups.groups), + serializeJson(strippedPool.items), + now(), + row.id, + ]) + stats.updatedTierLists += 1 + } + } + + for (const row of templateRequestRows) { + const groups = parseJson(row.groups_json, []) + const items = parseJson(row.items_json, []) + const boardItems = parseJson(row.board_items_json, []) + let changed = false + let nextThumbnail = row.thumbnail_src_snapshot || '' + + if (row.thumbnail_src_snapshot && !(await fileExistsForUploadSrc(row.thumbnail_src_snapshot))) { + nextThumbnail = '' + changed = true + } + + const strippedItems = stripMissingItems(items, missingCustomItemIds, missingCustomSrcs) + const strippedBoardItems = stripMissingItems(boardItems, missingCustomItemIds, missingCustomSrcs) + const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds) + if (strippedItems.changed || strippedBoardItems.changed || strippedGroups.changed) changed = true + + if (changed) { + await query( + 'UPDATE template_requests SET thumbnail_src_snapshot = ?, groups_json = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', + [ + nextThumbnail, + serializeJson(strippedGroups.groups), + serializeJson(strippedItems.items), + serializeJson(strippedBoardItems.items), + now(), + row.id, + ] + ) + stats.updatedTemplateRequests += 1 + } + } + + await deleteCustomItems(Array.from(missingCustomItemIds)) + stats.deletedCustomItems = missingCustomItemIds.size + } + + return stats +} + async function getImageAssetStats({ month } = {}) { const range = resolveMonthRange(month) const jobWhere = [] @@ -2188,6 +2338,7 @@ module.exports = { replaceUploadSourceReferences, clearImageOptimizationJobs, getImageAssetStats, + cleanupMissingUploadReferences, createGameItem, updateGameItemLabel, updateGameItemDisplayOrder, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index c2a7d9b..ae0eeb7 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -44,6 +44,7 @@ const { getImageAssetStats, listRecentImageOptimizationJobs, clearImageOptimizationJobs, + cleanupMissingUploadReferences, } = require('../db') const { requireAdmin } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage') @@ -386,6 +387,11 @@ router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => { res.json({ deletedCount }) }) +router.post('/image-assets/missing/cleanup', requireAdmin, async (req, res) => { + const result = await cleanupMissingUploadReferences() + res.json({ result }) +}) + async function removeUploadFiles(srcs) { await Promise.all( (srcs || []).map(async (src) => { diff --git a/docs/history.md b/docs/history.md index 35629e0..9e5562c 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,8 @@ # 의사결정 이력 +## 2026-04-02 v1.3.65 +- 누락 파일 수치가 계속 쌓이는 상태에서는 원인 분석만으로는 운영 부담이 줄지 않으므로, 실제 파일이 없는 참조만 골라 썸네일/아바타/게임 아이템/커스텀 아이템 참조를 정리하는 관리자 액션을 제공하는 편이 맞다고 정리했다. + ## 2026-04-02 v1.3.64 - 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다. - 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index 44ada03..ee89e73 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -7,6 +7,7 @@ - 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다. - 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다. - 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다. +- `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다. ## 중기 개선 - 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다. diff --git a/docs/update.md b/docs/update.md index 6729195..59a2a5d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,9 @@ # 업데이트 로그 +## 2026-04-02 v1.3.65 +- 관리자 이미지 최적화 패널에 `누락 참조 정리` 액션을 추가해, 실제 파일이 없는 `/uploads/...` 참조만 대상으로 썸네일/아바타는 비우고 누락된 게임 아이템·커스텀 아이템은 관련 티어표/템플릿 요청 참조와 함께 정리할 수 있게 함. +- 따라서 예전 수동 파일 정리나 레거시 데이터로 인해 쌓인 `누락 파일`은 단순 통계로만 남지 않고, 관리자 화면에서 실제로 줄일 수 있는 관리 도구를 갖게 됨. + ## 2026-04-02 v1.3.64 - 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임. - 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 5319cae..2b9df9c 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -55,6 +55,7 @@ export const api = { return request(`/api/admin/image-assets/stats?${query.toString()}`) }, resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }), + cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }), 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 f9ed5af..3edcba2 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -90,6 +90,7 @@ const imageRecentJobs = ref([]) const imageStatsMonth = ref('') const imageStatsLimit = ref(12) const imageResetModalOpen = ref(false) +const imageMissingCleanupBusy = ref(false) const error = ref('') const success = ref('') @@ -667,6 +668,23 @@ async function confirmImageReset() { } } +async function cleanupMissingImageReferences() { + const ok = window.confirm('파일이 실제로 없는 이미지 참조만 정리할까요? 누락된 썸네일은 비워지고, 누락된 게임/커스텀 아이템은 관련 참조와 함께 정리됩니다.') + if (!ok) return + + try { + imageMissingCleanupBusy.value = true + const data = await api.cleanupAdminMissingImageReferences() + await Promise.all([refreshImageDiagnostics(), refreshGames(), refreshCustomItems(), refreshTemplateRequests()]) + const result = data.result || {} + success.value = `누락 참조를 정리했어요. 아바타 ${result.clearedAvatars || 0}건, 게임 썸네일 ${result.clearedGameThumbnails || 0}건, 게임 아이템 ${result.deletedGameItems || 0}건, 커스텀 아이템 ${result.deletedCustomItems || 0}건` + } catch (e) { + error.value = '누락 이미지 참조 정리에 실패했어요.' + } finally { + imageMissingCleanupBusy.value = false + } +} + function setTab(tab) { resetMessages() const nextRouteName = adminRouteNameByTab[tab] @@ -2065,6 +2083,11 @@ function userAvatarFallback(user) { +
+ +
{{ imageStatsPeriodLabel }}