fix: 대체 아이템 삭제와 미사용 정리 기준 보정
This commit is contained in:
@@ -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 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 params = hasQuery ? [search, search, search, search] : []
|
||||||
|
|
||||||
const rows = await query(
|
const [rows, topicItemRows, usageMeta] = await Promise.all([
|
||||||
`
|
query(
|
||||||
SELECT
|
`
|
||||||
c.id,
|
SELECT
|
||||||
c.owner_id,
|
c.id,
|
||||||
c.src,
|
c.owner_id,
|
||||||
c.label,
|
c.src,
|
||||||
c.replaced_by_item_id,
|
c.label,
|
||||||
c.replaced_by_src,
|
c.replaced_by_item_id,
|
||||||
c.replaced_by_label,
|
c.replaced_by_src,
|
||||||
c.replaced_at,
|
c.replaced_by_label,
|
||||||
c.created_at,
|
c.replaced_at,
|
||||||
u.nickname,
|
c.created_at,
|
||||||
u.email
|
u.nickname,
|
||||||
FROM custom_items c
|
u.email
|
||||||
INNER JOIN users u ON u.id = c.owner_id
|
FROM custom_items c
|
||||||
${whereClause}
|
INNER JOIN users u ON u.id = c.owner_id
|
||||||
ORDER BY c.created_at DESC
|
${whereClause}
|
||||||
`,
|
ORDER BY c.created_at DESC
|
||||||
params
|
`,
|
||||||
)
|
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
|
return rows
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
...mapCustomItemRow(row),
|
...mapCustomItemRow(row),
|
||||||
ownerName: row.nickname || row.email,
|
ownerName: row.nickname || row.email,
|
||||||
ownerEmail: 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 = '') {
|
async function getFavoriteStatsForTierListIds(tierListIds, userId = '') {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const {
|
|||||||
listRecentImageOptimizationJobs,
|
listRecentImageOptimizationJobs,
|
||||||
clearImageOptimizationJobs,
|
clearImageOptimizationJobs,
|
||||||
cleanupMissingUploadReferences,
|
cleanupMissingUploadReferences,
|
||||||
|
listReferencedUploadSources,
|
||||||
replaceUploadSourceReferences,
|
replaceUploadSourceReferences,
|
||||||
updateCustomItemDisplayReferences,
|
updateCustomItemDisplayReferences,
|
||||||
clearCustomItemReplacement,
|
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 }) {
|
async function promoteLibraryItemToTemplateItem({ item, templateId }) {
|
||||||
return createTopicItem({
|
return createTopicItem({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
@@ -776,13 +783,14 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
|||||||
return res.json({ ok: true, sourceType: 'template' })
|
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.canDelete) return res.status(409).json({ error: 'item_locked' })
|
||||||
if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
|
if (!canDeleteReplacedUserItem && 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.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
|
||||||
|
|
||||||
const items = await findCustomItemsByIds([target.id])
|
const items = await findCustomItemsByIds([target.id])
|
||||||
await deleteCustomItems([target.id])
|
await deleteCustomItems([target.id])
|
||||||
await removeCustomItemFiles(items)
|
await removeUnreferencedCustomItemFiles(items)
|
||||||
res.json({ ok: true, sourceType: 'user' })
|
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 items = await findUnusedCustomItems({ queryText: parsed.data.q })
|
||||||
const ids = items.map((item) => item.id)
|
const ids = items.map((item) => item.id)
|
||||||
await deleteCustomItems(ids)
|
await deleteCustomItems(ids)
|
||||||
await removeCustomItemFiles(items)
|
await removeUnreferencedCustomItemFiles(items)
|
||||||
res.json({ ok: true, deletedCount: ids.length })
|
res.json({ ok: true, deletedCount: ids.length })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.84
|
||||||
|
- 대체된 원본 아이템은 운영상 이미 교체 완료된 이력이므로, 현재 저장본에서 같은 item id 사용량이 남아 있더라도 개별 삭제를 막아 두는 쪽보다 명시적으로 정리 가능하게 두는 편이 더 맞다고 판단했다.
|
||||||
|
- 또 관리자 목록에서 보이는 `미사용` 기준과 실제 일괄 삭제 API 기준이 다르면 운영자가 버튼 상태를 신뢰하기 어려워지므로, 둘은 반드시 같은 조건으로 맞추기로 정리했다.
|
||||||
|
- 마지막으로 업로드 파일은 하나의 레코드가 없어졌다고 곧바로 지우기보다, 전체 참조를 다시 확인한 뒤 정말 고아 파일일 때만 삭제하는 방식이 더 안전하다고 판단했다.
|
||||||
|
|
||||||
## 2026-04-06 v1.4.83
|
## 2026-04-06 v1.4.83
|
||||||
- 대체 이력이 쌓이기 시작하면 일반 사용자 업로드 목록 안에 섞여 보이는 것보다, 운영자가 후속 검수와 정리를 위해 `대체된 항목만` 따로 모아 보는 필터가 있는 편이 더 실용적이라고 판단했다.
|
- 대체 이력이 쌓이기 시작하면 일반 사용자 업로드 목록 안에 섞여 보이는 것보다, 운영자가 후속 검수와 정리를 위해 `대체된 항목만` 따로 모아 보는 필터가 있는 편이 더 실용적이라고 판단했다.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.84
|
||||||
|
- 대체된 사용자 업로드 아이템은 이미 다른 이미지로 참조가 옮겨진 상태라면, 관리자 개별 삭제에서 더 이상 `삭제 실패`로 막히지 않도록 삭제 조건을 보정했다.
|
||||||
|
- `미사용 아이템` 필터 화면과 `미사용 아이템 일괄 삭제` API가 같은 기준으로 움직이도록 맞췄다. 이제 사용 중인 티어표가 0개이고 템플릿 연결도 없을 때만 일반 미사용으로 잡히고, 대체된 아이템은 별도 예외로 계속 정리 대상에 포함된다.
|
||||||
|
- 커스텀 이미지 레코드를 지운 뒤 실제 업로드 파일을 정리할 때는, 다른 곳에서 같은 파일 경로를 아직 참조 중이면 파일은 남겨 두도록 안전장치를 추가했다.
|
||||||
|
|
||||||
## 2026-04-06 v1.4.83
|
## 2026-04-06 v1.4.83
|
||||||
- 관리자 아이템 관리 필터에 `대체된 아이템` 모드를 추가해, 이미지 대체 이력이 있는 사용자 업로드 항목만 따로 모아 볼 수 있게 했다.
|
- 관리자 아이템 관리 필터에 `대체된 아이템` 모드를 추가해, 이미지 대체 이력이 있는 사용자 업로드 항목만 따로 모아 볼 수 있게 했다.
|
||||||
- 이 필터는 `replaced_at` 메타가 있는 사용자 아이템만 대상으로 하므로, 운영자는 대체 이력 검수와 후속 정리를 일반 사용자 업로드 목록과 분리해 확인할 수 있다.
|
- 이 필터는 `replaced_at` 메타가 있는 사용자 아이템만 대상으로 하므로, 운영자는 대체 이력 검수와 후속 정리를 일반 사용자 업로드 목록과 분리해 확인할 수 있다.
|
||||||
|
|||||||
@@ -657,6 +657,14 @@ const visibleLinkedTemplates = computed(() =>
|
|||||||
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
|
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
|
||||||
const canReplaceModalTarget = computed(() => modalTargetCustomItem.value?.sourceType === 'user')
|
const canReplaceModalTarget = computed(() => modalTargetCustomItem.value?.sourceType === 'user')
|
||||||
const replacementCandidateCount = computed(() => customItemReplacementItems.value.length)
|
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 imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
||||||
const imageStatsYearOptions = computed(() => {
|
const imageStatsYearOptions = computed(() => {
|
||||||
@@ -2454,7 +2462,7 @@ function openUserProfile(user) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="adminSidebar__actions">
|
<div class="adminSidebar__actions">
|
||||||
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
||||||
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 아이템 일괄 삭제</button>
|
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !hasDeletableUnusedCustomItems" @click="removeUnusedCustomItems">미사용 아이템 일괄 삭제</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="adminSidebar__stats">
|
<div class="adminSidebar__stats">
|
||||||
<div class="sidebarStat">
|
<div class="sidebarStat">
|
||||||
|
|||||||
Reference in New Issue
Block a user