Compare commits

...

2 Commits

6 changed files with 133 additions and 39 deletions

View File

@@ -1897,7 +1897,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
src: row.src, src: row.src,
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음', label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
createdAt: Number(row.created_at || 0), createdAt: Number(row.created_at || 0),
ownerName: '관리자 보관 자산', ownerName: '관리자 미사용 이미지',
ownerEmail: '', ownerEmail: '',
usageCount: 0, usageCount: 0,
linkedTemplates: [], linkedTemplates: [],
@@ -1997,6 +1997,8 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
return item.assetKind === 'avatar' return item.assetKind === 'avatar'
case 'library': case 'library':
return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem) return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
case 'unused':
return (item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt)) || item.sourceType === 'asset' || !!item.isAssetLibraryItem
case 'unused-user': case 'unused-user':
return item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt) return item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt)
case 'replaced-user': case 'replaced-user':
@@ -2027,37 +2029,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 = '') {

View File

@@ -54,6 +54,7 @@ const {
listRecentImageOptimizationJobs, listRecentImageOptimizationJobs,
clearImageOptimizationJobs, clearImageOptimizationJobs,
cleanupMissingUploadReferences, cleanupMissingUploadReferences,
listReferencedUploadSources,
replaceUploadSourceReferences, replaceUploadSourceReferences,
updateCustomItemDisplayReferences, updateCustomItemDisplayReferences,
clearCustomItemReplacement, clearCustomItemReplacement,
@@ -360,7 +361,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
page: z.coerce.number().int().min(1).optional().default(1), page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50), limit: z.coerce.number().int().min(1).max(200).optional().default(50),
filter: z filter: z
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'replaced-user', 'unused-admin']) .enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused', 'unused-user', 'replaced-user', 'unused-admin'])
.optional() .optional()
.default('library'), .default('library'),
}) })
@@ -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' })
}) })
@@ -1132,11 +1140,27 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.query) const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const items = await findUnusedCustomItems({ queryText: parsed.data.q }) const result = await listCustomItems({
const ids = items.map((item) => item.id) queryText: parsed.data.q,
await deleteCustomItems(ids) page: 1,
await removeCustomItemFiles(items) limit: 10000,
res.json({ ok: true, deletedCount: ids.length }) filterMode: 'unused',
})
const customItems = result.items.filter((item) => item?.sourceType === 'user')
const assetItems = result.items.filter((item) => item?.sourceType === 'asset' || item?.isAssetLibraryItem)
const customItemIds = customItems.map((item) => item.id)
const assetIds = assetItems
.map((item) => String(item.id || ''))
.filter((id) => id.startsWith('asset:'))
.map((id) => id.slice('asset:'.length))
await deleteCustomItems(customItemIds)
await removeUnreferencedCustomItemFiles(customItems)
const deletedAssets = await deleteImageAssets(assetIds)
await removeImageAssetFiles(deletedAssets)
res.json({ ok: true, deletedCount: customItemIds.length + deletedAssets.length })
}) })
router.get('/users', requireAdmin, async (req, res) => { router.get('/users', requireAdmin, async (req, res) => {

View File

@@ -1,5 +1,14 @@
# 의사결정 이력 # 의사결정 이력
## 2026-04-06 v1.4.85
- 썸네일과 프로필 이미지는 이미 별도 필터로 분리돼 있고, 템플릿 아이템/사용자 아이템도 각각 구분되고 있으므로, 그 어디에도 속하지 않는 관리자 업로드 자산은 운영 의미상 `보관 자산`보다 `미사용 이미지`로 보는 편이 더 직관적이라고 정리했다.
- 또 관리자 아이템 화면에서 삭제 버튼이 필터 조건에 따라 갑자기 비활성으로만 보이면 흐름을 이해하기 어려우므로, 평소에는 `미사용 이미지 확인`, 해당 화면 안에서만 `미사용 이미지 일괄 삭제`로 바뀌는 점진적 동작이 더 낫다고 판단했다.
## 2026-04-06 v1.4.84
- 대체된 원본 아이템은 운영상 이미 교체 완료된 이력이므로, 현재 저장본에서 같은 item id 사용량이 남아 있더라도 개별 삭제를 막아 두는 쪽보다 명시적으로 정리 가능하게 두는 편이 더 맞다고 판단했다.
- 또 관리자 목록에서 보이는 `미사용` 기준과 실제 일괄 삭제 API 기준이 다르면 운영자가 버튼 상태를 신뢰하기 어려워지므로, 둘은 반드시 같은 조건으로 맞추기로 정리했다.
- 마지막으로 업로드 파일은 하나의 레코드가 없어졌다고 곧바로 지우기보다, 전체 참조를 다시 확인한 뒤 정말 고아 파일일 때만 삭제하는 방식이 더 안전하다고 판단했다.
## 2026-04-06 v1.4.83 ## 2026-04-06 v1.4.83
- 대체 이력이 쌓이기 시작하면 일반 사용자 업로드 목록 안에 섞여 보이는 것보다, 운영자가 후속 검수와 정리를 위해 `대체된 항목만` 따로 모아 보는 필터가 있는 편이 더 실용적이라고 판단했다. - 대체 이력이 쌓이기 시작하면 일반 사용자 업로드 목록 안에 섞여 보이는 것보다, 운영자가 후속 검수와 정리를 위해 `대체된 항목만` 따로 모아 보는 필터가 있는 편이 더 실용적이라고 판단했다.

View File

@@ -1,5 +1,16 @@
# 업데이트 로그 # 업데이트 로그
## 2026-04-06 v1.4.85
- 관리자 아이템 관리의 `미사용 이미지` 범위를 다시 정리해, 사용자 업로드 미사용 항목뿐 아니라 현재 어디에도 연결되지 않은 관리자 자산(`asset`)도 같은 미사용 이미지 목록에서 함께 보이도록 통합했다.
- 그래서 이제 관리자 업로드 이미지라도 템플릿 아이템, 사용자 아이템, 썸네일, 프로필 등 어느 경로에도 연결되지 않으면 `미사용 이미지`로 보고 일괄 삭제할 수 있다.
- 필터 UI도 함께 다듬어, 다른 필터를 보고 있을 때는 위험한 삭제 버튼 대신 `미사용 이미지 확인` 버튼을 보여주고, 그 화면에 들어왔을 때만 `미사용 이미지 일괄 삭제` 버튼이 나타나도록 바꿨다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
## 2026-04-06 v1.4.84
- 대체된 사용자 업로드 아이템은 이미 다른 이미지로 참조가 옮겨진 상태라면, 관리자 개별 삭제에서 더 이상 `삭제 실패`로 막히지 않도록 삭제 조건을 보정했다.
- `미사용 아이템` 필터 화면과 `미사용 아이템 일괄 삭제` API가 같은 기준으로 움직이도록 맞췄다. 이제 사용 중인 티어표가 0개이고 템플릿 연결도 없을 때만 일반 미사용으로 잡히고, 대체된 아이템은 별도 예외로 계속 정리 대상에 포함된다.
- 커스텀 이미지 레코드를 지운 뒤 실제 업로드 파일을 정리할 때는, 다른 곳에서 같은 파일 경로를 아직 참조 중이면 파일은 남겨 두도록 안전장치를 추가했다.
## 2026-04-06 v1.4.83 ## 2026-04-06 v1.4.83
- 관리자 아이템 관리 필터에 `대체된 아이템` 모드를 추가해, 이미지 대체 이력이 있는 사용자 업로드 항목만 따로 모아 볼 수 있게 했다. - 관리자 아이템 관리 필터에 `대체된 아이템` 모드를 추가해, 이미지 대체 이력이 있는 사용자 업로드 항목만 따로 모아 볼 수 있게 했다.
- 이 필터는 `replaced_at` 메타가 있는 사용자 아이템만 대상으로 하므로, 운영자는 대체 이력 검수와 후속 정리를 일반 사용자 업로드 목록과 분리해 확인할 수 있다. - 이 필터는 `replaced_at` 메타가 있는 사용자 아이템만 대상으로 하므로, 운영자는 대체 이력 검수와 후속 정리를 일반 사용자 업로드 목록과 분리해 확인할 수 있다.

View File

@@ -175,18 +175,26 @@ export function useAdminCustomItems({
async function removeUnusedCustomItems() { async function removeUnusedCustomItems() {
resetMessages() resetMessages()
const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?') const ok = window.confirm('현재 검색 조건에 맞는 미사용 이미지를 모두 삭제할까요?')
if (!ok) return if (!ok) return
try { try {
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value }) const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
await refreshCustomItems() await refreshCustomItems()
success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.` success.value = `${data.deletedCount || 0}개의 미사용 이미지를 삭제했어요.`
} catch (e) { } catch (e) {
error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.' error.value = '미사용 이미지 일괄 삭제에 실패했어요.'
} }
} }
function showUnusedCustomItems() {
if (customItemFilter.value === 'unused') return
resetMessages()
customItemFilter.value = 'unused'
customItemPage.value = 1
refreshCustomItems()
}
async function saveCustomItemModalLabel() { async function saveCustomItemModalLabel() {
const item = modalTargetCustomItem.value const item = modalTargetCustomItem.value
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60) const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
@@ -291,6 +299,7 @@ export function useAdminCustomItems({
jumpToTemplateAdmin, jumpToTemplateAdmin,
removeCustomItem, removeCustomItem,
removeUnusedCustomItems, removeUnusedCustomItems,
showUnusedCustomItems,
saveCustomItemModalLabel, saveCustomItemModalLabel,
promoteCustomItem, promoteCustomItem,
refreshReplacementCandidates, refreshReplacementCandidates,

View File

@@ -657,6 +657,16 @@ 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 hasDeletableUnusedItems = computed(() =>
customItems.value.some(
(item) =>
(item?.sourceType === 'user' &&
(!!item.replacedAt ||
(Number(item.usageCount || 0) === 0 && !(Array.isArray(item.linkedTemplates) && item.linkedTemplates.length > 0)))) ||
item?.sourceType === 'asset' ||
!!item?.isAssetLibraryItem
)
)
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간')) const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
const imageStatsYearOptions = computed(() => { const imageStatsYearOptions = computed(() => {
@@ -1049,6 +1059,7 @@ const {
jumpToTemplateAdmin, jumpToTemplateAdmin,
removeCustomItem, removeCustomItem,
removeUnusedCustomItems, removeUnusedCustomItems,
showUnusedCustomItems,
saveCustomItemModalLabel, saveCustomItemModalLabel,
promoteCustomItem, promoteCustomItem,
refreshReplacementCandidates, refreshReplacementCandidates,
@@ -2449,12 +2460,20 @@ function openUserProfile(user) {
<option value="replaced-user">대체된 아이템</option> <option value="replaced-user">대체된 아이템</option>
<option value="thumbnail">썸네일 이미지</option> <option value="thumbnail">썸네일 이미지</option>
<option value="avatar">프로필 이미지</option> <option value="avatar">프로필 이미지</option>
<option value="unused-user">미사용 아이템</option> <option value="unused">미사용 이미지</option>
</select> </select>
</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
v-if="customItemFilter === 'unused'"
class="btn btn--danger"
:disabled="!hasDeletableUnusedItems"
@click="removeUnusedCustomItems"
>
미사용 이미지 일괄 삭제
</button>
<button v-else class="btn btn--ghost" @click="showUnusedCustomItems">미사용 이미지 확인</button>
</div> </div>
<div class="adminSidebar__stats"> <div class="adminSidebar__stats">
<div class="sidebarStat"> <div class="sidebarStat">