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 }}
+ 대체됨