From c7cafb87c38ea4918ffd5454aaf82894304b98fb Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 6 Apr 2026 11:00:02 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=8C=80=EC=B2=B4=EB=90=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4=20=EB=B0=8F=20=EC=83=81=ED=83=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 87 ++++++++++++------- backend/src/routes/admin.js | 11 ++- docs/history.md | 4 + docs/update.md | 6 ++ .../components/admin/AdminItemsSection.vue | 1 + frontend/src/views/AdminView.vue | 18 ++++ 6 files changed, 90 insertions(+), 37 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index c97ee7e..251ead8 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -115,6 +115,21 @@ function mapTopicItemRow(row) { } } +function mapCustomItemRow(row) { + if (!row) return null + return { + id: row.id, + ownerId: row.owner_id, + src: row.src, + label: row.label, + createdAt: Number(row.created_at), + replacedByItemId: row.replaced_by_item_id || '', + replacedBySrc: row.replaced_by_src || '', + replacedByLabel: row.replaced_by_label || '', + replacedAt: Number(row.replaced_at || 0), + } +} + function mapImageAssetRow(row) { if (!row) return null return { @@ -360,12 +375,21 @@ async function ensureSchema() { owner_id VARCHAR(64) NOT NULL, src VARCHAR(255) NOT NULL, label VARCHAR(120) NOT NULL, + replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '', + replaced_by_src VARCHAR(255) NOT NULL DEFAULT '', + replaced_by_label VARCHAR(120) NOT NULL DEFAULT '', + replaced_at BIGINT NOT NULL DEFAULT 0, created_at BIGINT NOT NULL, INDEX idx_custom_items_owner_id (owner_id), CONSTRAINT fk_custom_items_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '' AFTER label`) + await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_src VARCHAR(255) NOT NULL DEFAULT '' AFTER replaced_by_item_id`) + await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_label VARCHAR(120) NOT NULL DEFAULT '' AFTER replaced_by_src`) + await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_at BIGINT NOT NULL DEFAULT 0 AFTER replaced_by_label`) + await query(` CREATE TABLE IF NOT EXISTS tierlists ( id VARCHAR(64) PRIMARY KEY, @@ -1518,7 +1542,7 @@ async function updateTopicItemDisplayOrder(topicId, itemIds) { async function updateCustomItemLabel(itemId, label) { await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId]) const rows = await query(` - SELECT c.id, c.owner_id, c.src, c.label, c.created_at, u.nickname, u.email + 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 WHERE c.id = ? @@ -1527,16 +1551,20 @@ async function updateCustomItemLabel(itemId, label) { const row = rows[0] if (!row) return null return { - id: row.id, - ownerId: row.owner_id, - src: row.src, - label: row.label, - createdAt: Number(row.created_at), + ...mapCustomItemRow(row), ownerName: row.nickname || row.email, ownerEmail: row.email, } } +async function markCustomItemReplaced({ itemId, replacedByItemId = '', replacedBySrc = '', replacedByLabel = '' }) { + await query( + 'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = ? WHERE id = ?', + [replacedByItemId || '', replacedBySrc || '', replacedByLabel || '', now(), 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]) @@ -1626,7 +1654,7 @@ async function syncOwnedCustomItemLabels({ ownerId, items }) { async function findCustomItemById(id) { const rows = await query( ` - SELECT id, owner_id, src, label, created_at + SELECT id, owner_id, src, label, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at FROM custom_items WHERE id = ? LIMIT 1 @@ -1635,14 +1663,7 @@ async function findCustomItemById(id) { ) const row = rows[0] - if (!row) return null - return { - id: row.id, - ownerId: row.owner_id, - src: row.src, - label: row.label, - createdAt: Number(row.created_at), - } + return mapCustomItemRow(row) } async function getCustomItemUsageMeta() { @@ -1707,6 +1728,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod 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 @@ -1786,17 +1811,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()) return { id: row.id, - ownerId: row.owner_id, - src: row.src, - label: row.label, - createdAt: Number(row.created_at), + ...mapCustomItemRow(row), ownerName: row.nickname || row.email, ownerEmail: row.email, usageCount: usageMeta.usageMap.get(row.id) || 0, linkedTemplates, assetKind: resolveLibraryAssetKind(row.src), sourceType: 'user', - sourceLabel: '사용자 아이템', + sourceLabel: Number(row.replaced_at || 0) > 0 ? '대체된 사용자 아이템' : '사용자 아이템', canDelete: true, } }) @@ -1895,6 +1917,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod usageCount: entry.usageCount || 0, linkedTemplates: entry.linkedTemplates || [], isAssetLibraryItem: !!entry.isAssetLibraryItem, + replacedByItemId: entry.replacedByItemId || '', + replacedBySrc: entry.replacedBySrc || '', + replacedByLabel: entry.replacedByLabel || '', + replacedAt: entry.replacedAt || 0, })), } }) @@ -1947,6 +1973,10 @@ async function findUnusedCustomItems({ queryText = '' } = {}) { 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 @@ -1961,11 +1991,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) { const { usageMap } = await getCustomItemUsageMeta() return rows .map((row) => ({ - id: row.id, - ownerId: row.owner_id, - src: row.src, - label: row.label, - createdAt: Number(row.created_at), + ...mapCustomItemRow(row), ownerName: row.nickname || row.email, ownerEmail: row.email, usageCount: usageMap.get(row.id) || 0, @@ -2895,20 +2921,14 @@ async function findCustomItemsByIds(ids) { const placeholders = ids.map(() => '?').join(', ') const rows = await query( ` - SELECT id, owner_id, src, label, created_at + SELECT id, owner_id, src, label, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at FROM custom_items WHERE id IN (${placeholders}) `, ids ) - return rows.map((row) => ({ - id: row.id, - ownerId: row.owner_id, - src: row.src, - label: row.label, - createdAt: Number(row.created_at), - })) + return rows.map(mapCustomItemRow) } async function deleteCustomItems(ids) { @@ -3064,6 +3084,7 @@ module.exports = { deleteTopic, updateTopicDisplayOrder, updateCustomItemLabel, + markCustomItemReplaced, updateImageAssetLabel, createCustomItem, findCustomItemById, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 79fbe17..0dbe98a 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -33,6 +33,7 @@ const { findUnusedCustomItems, findCustomItemsByIds, deleteCustomItems, + markCustomItemReplaced, listUsers, findPrimaryAdminUser, listAdminTierLists, @@ -835,10 +836,12 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => { toSrc: targetItem.src, toLabel: targetItem.label || '', }) - - const sourceCustomItems = await findCustomItemsByIds([sourceItem.id]) - await deleteCustomItems([sourceItem.id]) - await removeCustomItemFiles(sourceCustomItems) + await markCustomItemReplaced({ + itemId: sourceItem.id, + replacedByItemId: targetItem.id || '', + replacedBySrc: targetItem.src || '', + replacedByLabel: targetItem.label || '', + }) res.json({ ok: true, diff --git a/docs/history.md b/docs/history.md index 91b1c43..685943e 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-06 v1.4.80 +- 이미지 대체 직후 원본 사용자 아이템을 완전히 지워버리면 관리자 입장에서는 “왜 바꿨는지”, “나중에 정리해도 되는지”를 다시 확인할 근거가 사라지므로, 참조 이동과 원본 보존을 분리하는 편이 운영 흐름에 더 맞다고 판단했다. +- 다만 대체 완료된 원본까지 별도 보호 대상으로 빼면 라이브러리 정리가 끝없이 쌓일 수 있으므로, 원본은 `대체됨` 상태로 계속 보이게 하되 이미 미사용인 이상 `미사용 아이템 일괄 삭제`와 개별 삭제로 언제든 정리할 수 있게 두는 쪽이 균형이 맞는다고 정리했다. + ## 2026-04-06 v1.4.79 - 관리자 대체 모달은 열자마자 현재 라이브러리 결과를 그대로 쏟아내면 “같은 보드 안의 비슷한 항목을 고르는 화면”처럼 읽히기 쉬우므로, 검색 전에는 후보를 비워 두고 운영자가 의도적으로 찾은 뒤 고르는 방식이 더 분명하다고 판단했다. - 또 사용자 업로드 A를 F로 대체했을 때 관리자 목록에 F가 두 장 보이면 “참조 이동”보다 “복제 생성”처럼 느껴지므로, 사용자 업로드 대체는 참조를 옮긴 뒤 원본 레코드 자체를 정리해 결과적으로 목표 이미지 한 장만 남는 쪽이 운영 기대와 더 잘 맞는다고 정리했다. diff --git a/docs/update.md b/docs/update.md index 4ef8648..f9b98ad 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 2026-04-06 v1.4.80 +- 관리자 이미지 대체는 더 이상 원본 사용자 아이템을 즉시 삭제하지 않고, 원본 레코드와 파일을 남겨 둔 채 `어떤 아이템으로 대체됐는지` 메타만 기록하도록 바꿨다. +- 따라서 대체 후에도 원본 이미지는 아이템 라이브러리에서 계속 확인할 수 있고, 카드와 상세 모달에서 `대체됨` 상태와 대체 대상 라벨을 함께 볼 수 있다. +- 다만 이 원본은 이미 참조가 옮겨진 미사용 사용자 아이템이므로, 기존 `미사용 아이템 일괄 삭제`와 개별 삭제 대상에는 그대로 포함되게 유지해 운영자가 원할 때 정리할 수 있게 했다. +- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build` + ## 2026-04-06 v1.4.79 - 관리자 이미지 대체 모달은 처음 열었을 때 기존 목록을 자동으로 보여주지 않고, 검색을 실행한 뒤에만 대체 후보를 표시하도록 바꿨다. 같은 티어표/같은 문맥의 항목이 처음부터 섞여 보여 혼란스럽던 점을 줄이기 위한 조정이다. - 사용자 업로드 아이템을 다른 이미지로 대체할 때는 이제 원본 항목을 그대로 `대상 이미지와 라벨`로 덮어써 중복 항목을 남기지 않고, 참조를 옮긴 뒤 원본 사용자 아이템 레코드와 파일을 함께 정리해 관리자 라이브러리에 동일 이미지가 두 번 보이지 않도록 맞췄다. diff --git a/frontend/src/components/admin/AdminItemsSection.vue b/frontend/src/components/admin/AdminItemsSection.vue index 247471f..3d4ef97 100644 --- a/frontend/src/components/admin/AdminItemsSection.vue +++ b/frontend/src/components/admin/AdminItemsSection.vue @@ -25,6 +25,7 @@ const props = defineProps({ > {{ item.sourceLabel }} + 대체됨
{{ item.label }}
diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 1ceb722..955daac 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -2179,6 +2179,19 @@ function openUserProfile(user) {
아직 템플릿에 연결된 항목이 없어요.
+
+ 대체 상태 +
+
+
{{ modalTargetCustomItem.replacedByLabel || '대체 대상 이름 없음' }}
+
이 아이템은 위 대상 이미지로 대체된 상태예요.
+
+
+
+ 대체 시각 + {{ fmt(modalTargetCustomItem.replacedAt) }} +
+
이미지 다운로드