diff --git a/backend/src/db.js b/backend/src/db.js index 6a7e10d..19ece32 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -2027,37 +2027,57 @@ async function findUnusedCustomItems({ queryText = '' } = {}) { const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : '' const params = hasQuery ? [search, search, search, search] : [] - const rows = await query( - ` - SELECT - c.id, - c.owner_id, - c.src, - c.label, - c.replaced_by_item_id, - c.replaced_by_src, - c.replaced_by_label, - c.replaced_at, - c.created_at, - u.nickname, - u.email - FROM custom_items c - INNER JOIN users u ON u.id = c.owner_id - ${whereClause} - ORDER BY c.created_at DESC - `, - params - ) + const [rows, topicItemRows, usageMeta] = await Promise.all([ + query( + ` + SELECT + c.id, + c.owner_id, + c.src, + c.label, + c.replaced_by_item_id, + c.replaced_by_src, + c.replaced_by_label, + c.replaced_at, + c.created_at, + u.nickname, + u.email + FROM custom_items c + INNER JOIN users u ON u.id = c.owner_id + ${whereClause} + ORDER BY c.created_at DESC + `, + params + ), + query( + ` + SELECT ti.topic_id, tp.name AS topic_name, ti.src + FROM topic_items ti + LEFT JOIN topics tp ON tp.id = ti.topic_id + ` + ), + getCustomItemUsageMeta(), + ]) + + const templateLinkedBySrc = new Map() + topicItemRows.forEach((row) => { + if (!row?.src) return + if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map()) + templateLinkedBySrc.get(row.src).set(row.topic_id, { + id: row.topic_id, + name: row.topic_name || row.topic_id, + }) + }) - const { usageMap } = await getCustomItemUsageMeta() return rows .map((row) => ({ ...mapCustomItemRow(row), ownerName: row.nickname || row.email, ownerEmail: row.email, - usageCount: usageMap.get(row.id) || 0, + usageCount: usageMeta.usageMap.get(row.id) || 0, + linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), })) - .filter((item) => item.usageCount === 0 || !!item.replacedAt) + .filter((item) => ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt)) } async function getFavoriteStatsForTierListIds(tierListIds, userId = '') { diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index f11abb3..2931e44 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -54,6 +54,7 @@ const { listRecentImageOptimizationJobs, clearImageOptimizationJobs, cleanupMissingUploadReferences, + listReferencedUploadSources, replaceUploadSourceReferences, updateCustomItemDisplayReferences, clearCustomItemReplacement, @@ -546,6 +547,12 @@ async function removeCustomItemFiles(items) { ) } +async function removeUnreferencedCustomItemFiles(items) { + const referencedSrcs = new Set(await listReferencedUploadSources()) + const removableItems = (items || []).filter((item) => item?.src && !referencedSrcs.has(item.src)) + await removeCustomItemFiles(removableItems) +} + async function promoteLibraryItemToTemplateItem({ item, templateId }) { return createTopicItem({ id: nanoid(), @@ -776,13 +783,14 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { return res.json({ ok: true, sourceType: 'template' }) } + const canDeleteReplacedUserItem = target.sourceType === 'user' && !!target.replacedAt if (!target.canDelete) return res.status(409).json({ error: 'item_locked' }) - if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' }) - if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' }) + if (!canDeleteReplacedUserItem && target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' }) + if (!canDeleteReplacedUserItem && target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' }) const items = await findCustomItemsByIds([target.id]) await deleteCustomItems([target.id]) - await removeCustomItemFiles(items) + await removeUnreferencedCustomItemFiles(items) res.json({ ok: true, sourceType: 'user' }) }) @@ -1135,7 +1143,7 @@ router.delete('/custom-items', requireAdmin, async (req, res) => { const items = await findUnusedCustomItems({ queryText: parsed.data.q }) const ids = items.map((item) => item.id) await deleteCustomItems(ids) - await removeCustomItemFiles(items) + await removeUnreferencedCustomItemFiles(items) res.json({ ok: true, deletedCount: ids.length }) }) diff --git a/docs/history.md b/docs/history.md index 8981bf6..0950776 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-06 v1.4.84 +- 대체된 원본 아이템은 운영상 이미 교체 완료된 이력이므로, 현재 저장본에서 같은 item id 사용량이 남아 있더라도 개별 삭제를 막아 두는 쪽보다 명시적으로 정리 가능하게 두는 편이 더 맞다고 판단했다. +- 또 관리자 목록에서 보이는 `미사용` 기준과 실제 일괄 삭제 API 기준이 다르면 운영자가 버튼 상태를 신뢰하기 어려워지므로, 둘은 반드시 같은 조건으로 맞추기로 정리했다. +- 마지막으로 업로드 파일은 하나의 레코드가 없어졌다고 곧바로 지우기보다, 전체 참조를 다시 확인한 뒤 정말 고아 파일일 때만 삭제하는 방식이 더 안전하다고 판단했다. + ## 2026-04-06 v1.4.83 - 대체 이력이 쌓이기 시작하면 일반 사용자 업로드 목록 안에 섞여 보이는 것보다, 운영자가 후속 검수와 정리를 위해 `대체된 항목만` 따로 모아 보는 필터가 있는 편이 더 실용적이라고 판단했다. diff --git a/docs/update.md b/docs/update.md index 8e27b4d..db283bc 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-06 v1.4.84 +- 대체된 사용자 업로드 아이템은 이미 다른 이미지로 참조가 옮겨진 상태라면, 관리자 개별 삭제에서 더 이상 `삭제 실패`로 막히지 않도록 삭제 조건을 보정했다. +- `미사용 아이템` 필터 화면과 `미사용 아이템 일괄 삭제` API가 같은 기준으로 움직이도록 맞췄다. 이제 사용 중인 티어표가 0개이고 템플릿 연결도 없을 때만 일반 미사용으로 잡히고, 대체된 아이템은 별도 예외로 계속 정리 대상에 포함된다. +- 커스텀 이미지 레코드를 지운 뒤 실제 업로드 파일을 정리할 때는, 다른 곳에서 같은 파일 경로를 아직 참조 중이면 파일은 남겨 두도록 안전장치를 추가했다. + ## 2026-04-06 v1.4.83 - 관리자 아이템 관리 필터에 `대체된 아이템` 모드를 추가해, 이미지 대체 이력이 있는 사용자 업로드 항목만 따로 모아 볼 수 있게 했다. - 이 필터는 `replaced_at` 메타가 있는 사용자 아이템만 대상으로 하므로, 운영자는 대체 이력 검수와 후속 정리를 일반 사용자 업로드 목록과 분리해 확인할 수 있다. diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 0944248..529b46c 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -657,6 +657,14 @@ const visibleLinkedTemplates = computed(() => const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean))) const canReplaceModalTarget = computed(() => modalTargetCustomItem.value?.sourceType === 'user') const replacementCandidateCount = computed(() => customItemReplacementItems.value.length) +const hasDeletableUnusedCustomItems = computed(() => + customItems.value.some( + (item) => + item?.sourceType === 'user' && + (!!item.replacedAt || + (Number(item.usageCount || 0) === 0 && !(Array.isArray(item.linkedTemplates) && item.linkedTemplates.length > 0))) + ) +) const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간')) const imageStatsYearOptions = computed(() => { @@ -2454,7 +2462,7 @@ function openUserProfile(user) {