From 2d5506e35a1f0e459d2016b8e54c472311bf995d Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 6 Apr 2026 11:29:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EA=B8=B0=EC=A4=80=EA=B3=BC=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=9E=90=EC=82=B0=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 4 ++- backend/src/routes/admin.js | 28 +++++++++++++++---- docs/history.md | 4 +++ docs/update.md | 6 ++++ .../src/composables/useAdminCustomItems.js | 15 ++++++++-- frontend/src/views/AdminView.vue | 23 +++++++++++---- 6 files changed, 64 insertions(+), 16 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 19ece32..a4859a6 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1897,7 +1897,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod src: row.src, label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음', createdAt: Number(row.created_at || 0), - ownerName: '관리자 보관 자산', + ownerName: '관리자 미사용 이미지', ownerEmail: '', usageCount: 0, linkedTemplates: [], @@ -1997,6 +1997,8 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod return item.assetKind === 'avatar' case 'library': 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': return item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt) case 'replaced-user': diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 2931e44..91ee171 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -361,7 +361,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => { page: z.coerce.number().int().min(1).optional().default(1), limit: z.coerce.number().int().min(1).max(200).optional().default(50), 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() .default('library'), }) @@ -1140,11 +1140,27 @@ router.delete('/custom-items', requireAdmin, async (req, res) => { const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const items = await findUnusedCustomItems({ queryText: parsed.data.q }) - const ids = items.map((item) => item.id) - await deleteCustomItems(ids) - await removeUnreferencedCustomItemFiles(items) - res.json({ ok: true, deletedCount: ids.length }) + const result = await listCustomItems({ + queryText: parsed.data.q, + page: 1, + limit: 10000, + 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) => { diff --git a/docs/history.md b/docs/history.md index 0950776..664d362 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-06 v1.4.85 +- 썸네일과 프로필 이미지는 이미 별도 필터로 분리돼 있고, 템플릿 아이템/사용자 아이템도 각각 구분되고 있으므로, 그 어디에도 속하지 않는 관리자 업로드 자산은 운영 의미상 `보관 자산`보다 `미사용 이미지`로 보는 편이 더 직관적이라고 정리했다. +- 또 관리자 아이템 화면에서 삭제 버튼이 필터 조건에 따라 갑자기 비활성으로만 보이면 흐름을 이해하기 어려우므로, 평소에는 `미사용 이미지 확인`, 해당 화면 안에서만 `미사용 이미지 일괄 삭제`로 바뀌는 점진적 동작이 더 낫다고 판단했다. + ## 2026-04-06 v1.4.84 - 대체된 원본 아이템은 운영상 이미 교체 완료된 이력이므로, 현재 저장본에서 같은 item id 사용량이 남아 있더라도 개별 삭제를 막아 두는 쪽보다 명시적으로 정리 가능하게 두는 편이 더 맞다고 판단했다. - 또 관리자 목록에서 보이는 `미사용` 기준과 실제 일괄 삭제 API 기준이 다르면 운영자가 버튼 상태를 신뢰하기 어려워지므로, 둘은 반드시 같은 조건으로 맞추기로 정리했다. diff --git a/docs/update.md b/docs/update.md index db283bc..cf24ec0 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 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개이고 템플릿 연결도 없을 때만 일반 미사용으로 잡히고, 대체된 아이템은 별도 예외로 계속 정리 대상에 포함된다. diff --git a/frontend/src/composables/useAdminCustomItems.js b/frontend/src/composables/useAdminCustomItems.js index 66d9208..860c2b0 100644 --- a/frontend/src/composables/useAdminCustomItems.js +++ b/frontend/src/composables/useAdminCustomItems.js @@ -175,18 +175,26 @@ export function useAdminCustomItems({ async function removeUnusedCustomItems() { resetMessages() - const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?') + const ok = window.confirm('현재 검색 조건에 맞는 미사용 이미지를 모두 삭제할까요?') if (!ok) return try { const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value }) await refreshCustomItems() - success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.` + success.value = `${data.deletedCount || 0}개의 미사용 이미지를 삭제했어요.` } catch (e) { - error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.' + error.value = '미사용 이미지 일괄 삭제에 실패했어요.' } } + function showUnusedCustomItems() { + if (customItemFilter.value === 'unused') return + resetMessages() + customItemFilter.value = 'unused' + customItemPage.value = 1 + refreshCustomItems() + } + async function saveCustomItemModalLabel() { const item = modalTargetCustomItem.value const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60) @@ -291,6 +299,7 @@ export function useAdminCustomItems({ jumpToTemplateAdmin, removeCustomItem, removeUnusedCustomItems, + showUnusedCustomItems, saveCustomItemModalLabel, promoteCustomItem, refreshReplacementCandidates, diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 529b46c..70545c1 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -657,12 +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(() => +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 === 'user' && + (!!item.replacedAt || + (Number(item.usageCount || 0) === 0 && !(Array.isArray(item.linkedTemplates) && item.linkedTemplates.length > 0)))) || + item?.sourceType === 'asset' || + !!item?.isAssetLibraryItem ) ) @@ -1057,6 +1059,7 @@ const { jumpToTemplateAdmin, removeCustomItem, removeUnusedCustomItems, + showUnusedCustomItems, saveCustomItemModalLabel, promoteCustomItem, refreshReplacementCandidates, @@ -2457,12 +2460,20 @@ function openUserProfile(user) { - +
- + +