릴리스: v1.3.65 누락 이미지 참조 정리 도구 추가
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-02 v1.3.65
|
||||
- 누락 파일 수치가 계속 쌓이는 상태에서는 원인 분석만으로는 운영 부담이 줄지 않으므로, 실제 파일이 없는 참조만 골라 썸네일/아바타/게임 아이템/커스텀 아이템 참조를 정리하는 관리자 액션을 제공하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.64
|
||||
- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다.
|
||||
- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
|
||||
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
|
||||
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
|
||||
- `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다.
|
||||
|
||||
## 중기 개선
|
||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-02 v1.3.65
|
||||
- 관리자 이미지 최적화 패널에 `누락 참조 정리` 액션을 추가해, 실제 파일이 없는 `/uploads/...` 참조만 대상으로 썸네일/아바타는 비우고 누락된 게임 아이템·커스텀 아이템은 관련 티어표/템플릿 요청 참조와 함께 정리할 수 있게 함.
|
||||
- 따라서 예전 수동 파일 정리나 레거시 데이터로 인해 쌓인 `누락 파일`은 단순 통계로만 남지 않고, 관리자 화면에서 실제로 줄일 수 있는 관리 도구를 갖게 됨.
|
||||
|
||||
## 2026-04-02 v1.3.64
|
||||
- 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임.
|
||||
- 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함.
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
<button class="btn btn--ghost" @click="refreshImageDiagnostics">현황 새로고침</button>
|
||||
<button class="btn btn--ghost" @click="openImageResetModal">기록 비우기</button>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--danger" :disabled="!imageStats?.missingReferencedCount || imageMissingCleanupBusy" @click="cleanupMissingImageReferences">
|
||||
{{ imageMissingCleanupBusy ? '누락 참조 정리중...' : '누락 참조 정리' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hint hint--tight">{{ imageStatsPeriodLabel }}</div>
|
||||
<div v-if="imageDiagnosticsCards.length" class="adminSidebar__stats adminSidebar__stats--grid">
|
||||
<article v-for="stat in imageDiagnosticsCards" :key="stat.label" class="sidebarStat">
|
||||
|
||||
Reference in New Issue
Block a user