From a2fc8f8cd4b07f640bb5fd4954dd36eb9170875d Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 6 Apr 2026 11:09:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 65 ++++++++++++++++++- backend/src/routes/admin.js | 30 ++++++++- docs/history.md | 7 ++ docs/update.md | 11 ++++ .../src/composables/useAdminCustomItems.js | 26 +++++++- frontend/src/lib/api.js | 2 + frontend/src/views/AdminView.vue | 7 +- 7 files changed, 141 insertions(+), 7 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 1945340..6a7e10d 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1152,6 +1152,21 @@ function replaceItemSrc(items, fromSrc, toSrc, toLabel = '') { return { changed, items: nextItems } } +function replaceItemById(items, itemId, nextSrc, nextLabel = '') { + let changed = false + const normalizedLabel = typeof nextLabel === 'string' ? nextLabel.trim().slice(0, 60) : '' + const nextItems = (items || []).map((item) => { + if (item?.id !== itemId) return item + changed = true + return { + ...item, + ...(typeof nextSrc === 'string' && nextSrc ? { src: nextSrc } : {}), + ...(normalizedLabel ? { label: normalizedLabel } : {}), + } + }) + return { changed, items: nextItems } +} + async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '', updateCustomItemsBySrc = true }) { if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 } const normalizedLabel = typeof toLabel === 'string' ? toLabel.trim().slice(0, 60) : '' @@ -1222,6 +1237,40 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '', upd return { updatedRows } } +async function updateCustomItemDisplayReferences({ itemId, src = '', label = '' }) { + if (!itemId) return { updatedRows: 0 } + const normalizedLabel = typeof label === 'string' ? label.trim().slice(0, 60) : '' + let updatedRows = 0 + + const tierListRows = await query('SELECT id, pool_json FROM tierlists') + for (const row of tierListRows) { + const replacedPool = replaceItemById(parseJson(row.pool_json, []), itemId, src, normalizedLabel) + if (!replacedPool.changed) continue + await query('UPDATE tierlists SET pool_json = ?, updated_at = ? WHERE id = ?', [ + serializeJson(replacedPool.items), + now(), + row.id, + ]) + updatedRows += 1 + } + + const requestRows = await query('SELECT id, items_json, board_items_json FROM template_requests') + for (const row of requestRows) { + const replacedItems = replaceItemById(parseJson(row.items_json, []), itemId, src, normalizedLabel) + const replacedBoardItems = replaceItemById(parseJson(row.board_items_json, []), itemId, src, normalizedLabel) + if (!replacedItems.changed && !replacedBoardItems.changed) continue + await query('UPDATE template_requests SET items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [ + serializeJson(replacedItems.items), + serializeJson(replacedBoardItems.items), + now(), + row.id, + ]) + updatedRows += 1 + } + + return { updatedRows } +} + async function listImageAssets() { const rows = await query( 'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC' @@ -1567,6 +1616,14 @@ async function markCustomItemReplaced({ itemId, replacedByItemId = '', replacedB return findCustomItemById(itemId) } +async function clearCustomItemReplacement(itemId) { + await query( + 'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = 0 WHERE id = ?', + ['', '', '', itemId] + ) + return findCustomItemById(itemId) +} + async function updateImageAssetLabel(assetId, label) { await query('UPDATE image_assets SET label_override = ? WHERE id = ?', [label, assetId]) const rows = await query('SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [assetId]) @@ -1941,7 +1998,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod case 'library': return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem) case 'unused-user': - return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0 + return item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt) + case 'replaced-user': + return item.sourceType === 'user' && !!item.replacedAt case 'unused-admin': return item.sourceType === 'asset' || !!item.isAssetLibraryItem default: @@ -1998,7 +2057,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) { ownerEmail: row.email, usageCount: usageMap.get(row.id) || 0, })) - .filter((item) => item.usageCount === 0) + .filter((item) => item.usageCount === 0 || !!item.replacedAt) } async function getFavoriteStatsForTierListIds(tierListIds, userId = '') { @@ -3075,6 +3134,7 @@ module.exports = { listReferencedUploadSources, listReferencedUploadUsage, replaceUploadSourceReferences, + updateCustomItemDisplayReferences, clearImageOptimizationJobs, getImageAssetStats, cleanupMissingUploadReferences, @@ -3086,6 +3146,7 @@ module.exports = { deleteTopic, updateTopicDisplayOrder, updateCustomItemLabel, + clearCustomItemReplacement, markCustomItemReplaced, updateImageAssetLabel, createCustomItem, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 5b958d5..f11abb3 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -55,6 +55,8 @@ const { clearImageOptimizationJobs, cleanupMissingUploadReferences, replaceUploadSourceReferences, + updateCustomItemDisplayReferences, + clearCustomItemReplacement, } = require('../db') const { requireAdmin } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage') @@ -358,7 +360,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', 'unused-admin']) + .enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'replaced-user', 'unused-admin']) .optional() .default('library'), }) @@ -837,6 +839,11 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => { toLabel: targetItem.label || '', updateCustomItemsBySrc: false, }) + const displayResult = await updateCustomItemDisplayReferences({ + itemId: sourceItem.id, + src: targetItem.src, + label: targetItem.label || '', + }) await markCustomItemReplaced({ itemId: sourceItem.id, replacedByItemId: targetItem.id || '', @@ -846,12 +853,31 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => { res.json({ ok: true, - updatedRows: result.updatedRows || 0, + updatedRows: (result.updatedRows || 0) + (displayResult.updatedRows || 0), sourceItem, targetItem, }) }) +router.post('/custom-items/:itemId/restore', requireAdmin, async (req, res) => { + const sourceItem = await findCustomItemById(req.params.itemId) + if (!sourceItem?.id) return res.status(404).json({ error: 'not_found' }) + if (!sourceItem.replacedAt) return res.status(409).json({ error: 'not_replaced' }) + + const restored = await updateCustomItemDisplayReferences({ + itemId: sourceItem.id, + src: sourceItem.src, + label: sourceItem.label || '', + }) + await clearCustomItemReplacement(sourceItem.id) + + res.json({ + ok: true, + restoredRows: restored.updatedRows || 0, + item: await findCustomItemById(sourceItem.id), + }) +}) + router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => { const schema = z.object({ topicId: z.string().min(1), diff --git a/docs/history.md b/docs/history.md index 42b419d..8981bf6 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,12 @@ # 의사결정 이력 +## 2026-04-06 v1.4.83 +- 대체 이력이 쌓이기 시작하면 일반 사용자 업로드 목록 안에 섞여 보이는 것보다, 운영자가 후속 검수와 정리를 위해 `대체된 항목만` 따로 모아 보는 필터가 있는 편이 더 실용적이라고 판단했다. + +## 2026-04-06 v1.4.82 +- 원본 A를 보존하더라도 실제 저장본이 계속 A의 item id를 쓰는 구조라면, 대체/복구는 `custom_items` 레코드 자체를 바꾸는 방식보다 “그 item id가 참조되는 보드 표시 데이터(src/label)만 바꾸는 방식”이 더 자연스럽다고 판단했다. +- 또 대체된 원본은 집계상 사용량이 남더라도 운영 의미상 이미 정리 가능한 이력이므로, `미사용 아이템 일괄 삭제`에서 제외하면 오히려 계속 쌓이게 된다. 그래서 대체 이력 아이템은 예외적으로 미사용 정리 대상으로 포함하는 쪽이 맞다고 정리했다. + ## 2026-04-06 v1.4.81 - 원본 보존을 하더라도 실제 `custom_items.src`와 `label`까지 대체 대상 값으로 덮어쓰면 관리자 입장에서는 “원본을 남겼다”기보다 “같은 새 이미지가 두 번 보이는 상태”로 읽히므로, 원본 아이템 레코드는 실제 이미지/라벨을 그대로 유지하고 참조 이동 대상에서만 제외하는 편이 맞다고 정리했다. - 즉 대체 이력 메타(`어떤 아이템으로 대체됐는지`)와 원본 본문 데이터(`원래 A 이미지/이름`)는 분리해서 보존해야, 이후 복구나 검수 기준으로 의미가 살아난다고 판단했다. diff --git a/docs/update.md b/docs/update.md index 27b9678..8e27b4d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,16 @@ # 업데이트 로그 +## 2026-04-06 v1.4.83 +- 관리자 아이템 관리 필터에 `대체된 아이템` 모드를 추가해, 이미지 대체 이력이 있는 사용자 업로드 항목만 따로 모아 볼 수 있게 했다. +- 이 필터는 `replaced_at` 메타가 있는 사용자 아이템만 대상으로 하므로, 운영자는 대체 이력 검수와 후속 정리를 일반 사용자 업로드 목록과 분리해 확인할 수 있다. +- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build` + +## 2026-04-06 v1.4.82 +- 관리자 이미지 대체 구조를 다시 정리해, 대체 후에도 원본 A 아이템은 원래 썸네일/라벨 그대로 남고 `대체됨` 메타만 표시되도록 맞췄다. 더 이상 원본 카드가 F 이미지처럼 덮여 보이지 않는다. +- 실제 대체는 이제 `A 아이템 ID`가 가리키는 저장본 표시 정보만 새 이미지/라벨로 바꾸는 방식이라, 대체된 원본 카드에서 `원래 이미지로 복구`를 눌러 A 기준으로 되돌릴 수 있다. +- 대체된 사용자 아이템은 사용량 집계가 남아 있어도 운영상 이미 정리 가능한 상태로 간주해 `미사용 아이템 일괄 삭제`와 개별 삭제 대상에 포함되도록 조정했다. +- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build` + ## 2026-04-06 v1.4.81 - 관리자 이미지 대체 시 원본 사용자 아이템 레코드의 `src / label`까지 대체 대상 이미지로 덮어써져, 카드와 상세 모달에서 원래 A 이미지 정보를 다시 볼 수 없던 문제를 바로잡았다. - 이제 대체 처리에서는 티어표/요청/템플릿 참조만 새 이미지로 옮기고, 원본 사용자 아이템 레코드는 원래 이미지와 이름을 그대로 유지한 채 `대체됨` 메타만 남긴다. diff --git a/frontend/src/composables/useAdminCustomItems.js b/frontend/src/composables/useAdminCustomItems.js index 67815fc..66d9208 100644 --- a/frontend/src/composables/useAdminCustomItems.js +++ b/frontend/src/composables/useAdminCustomItems.js @@ -123,7 +123,7 @@ export function useAdminCustomItems({ function openCustomItemDeleteModal(item) { if (!item) return - if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) { + if (item.sourceType === 'user' && !item.replacedAt && (item.usageCount > 0 || item.linkedTemplates.length > 0)) { error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' return } @@ -147,7 +147,7 @@ export function useAdminCustomItems({ async function removeCustomItem(item = modalTargetCustomItem.value) { resetMessages() if (!item) return - if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) { + if (item.sourceType === 'user' && !item.replacedAt && (item.usageCount > 0 || item.linkedTemplates.length > 0)) { error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' return } @@ -257,6 +257,27 @@ export function useAdminCustomItems({ } } + async function restoreCustomItem(item = modalTargetCustomItem.value) { + resetMessages() + if (!item?.id || !item.replacedAt) { + error.value = '복구할 대체 이력이 없어요.' + return + } + + try { + customItemReplacementBusy.value = true + await api.restoreAdminCustomItem(item.id) + if (selectedTemplateId.value) await loadTemplate() + await refreshCustomItems() + closeCustomItemModal() + success.value = `"${item.label}" 아이템을 원래 이미지로 복구했어요.` + } catch (e) { + error.value = '원래 이미지로 복구하지 못했어요.' + } finally { + customItemReplacementBusy.value = false + } + } + return { submitCustomItemSearch, changeCustomItemFilter, @@ -274,5 +295,6 @@ export function useAdminCustomItems({ promoteCustomItem, refreshReplacementCandidates, replaceCustomItem, + restoreCustomItem, } } diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index fc497f5..343a3de 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -106,6 +106,8 @@ export const api = { request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }), replaceAdminCustomItem: (itemId, payload) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/replace`, { method: 'POST', body: payload }), + restoreAdminCustomItem: (itemId) => + request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/restore`, { method: 'POST', body: {} }), updateAdminCustomItemLabel: (itemId, payload) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }), promoteAdminTierListItems: (tierListId, payload) => diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 955daac..0944248 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1053,6 +1053,7 @@ const { promoteCustomItem, refreshReplacementCandidates, replaceCustomItem, + restoreCustomItem, } = useAdminCustomItems({ api, toast, @@ -2200,7 +2201,10 @@ function openUserProfile(user) { - + + @@ -2442,6 +2446,7 @@ function openUserProfile(user) { +