feat: 관리자 대체 아이템 전용 필터 추가

This commit is contained in:
2026-04-06 11:09:48 +09:00
parent b134431d91
commit ec04876c72
7 changed files with 141 additions and 7 deletions

View File

@@ -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,

View File

@@ -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),

View File

@@ -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 이미지/이름`)는 분리해서 보존해야, 이후 복구나 검수 기준으로 의미가 살아난다고 판단했다.

View File

@@ -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 이미지 정보를 다시 볼 수 없던 문제를 바로잡았다.
- 이제 대체 처리에서는 티어표/요청/템플릿 참조만 새 이미지로 옮기고, 원본 사용자 아이템 레코드는 원래 이미지와 이름을 그대로 유지한 채 `대체됨` 메타만 남긴다.

View File

@@ -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,
}
}

View File

@@ -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) =>

View File

@@ -1053,6 +1053,7 @@ const {
promoteCustomItem,
refreshReplacementCandidates,
replaceCustomItem,
restoreCustomItem,
} = useAdminCustomItems({
api,
toast,
@@ -2200,7 +2201,10 @@ function openUserProfile(user) {
<button v-if="canReplaceModalTarget" class="btn btn--primary customItemModal__action" :disabled="!customItemReplacementTargetId || customItemReplacementBusy" @click="replaceCustomItem(modalTargetCustomItem)">
{{ customItemReplacementBusy ? '대체중...' : '선택한 이미지로 대체' }}
</button>
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.sourceType === 'user' && (modalTargetCustomItem.usageCount > 0 || visibleLinkedTemplates.length > 0)" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
<button v-if="modalTargetCustomItem.replacedAt" class="btn btn--ghost customItemModal__action" :disabled="customItemReplacementBusy" @click="restoreCustomItem(modalTargetCustomItem)">
{{ customItemReplacementBusy ? '복구중...' : '원래 이미지로 복구' }}
</button>
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.sourceType === 'user' && !modalTargetCustomItem.replacedAt && (modalTargetCustomItem.usageCount > 0 || visibleLinkedTemplates.length > 0)" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
</div>
</div>
</div>
@@ -2442,6 +2446,7 @@ function openUserProfile(user) {
<option value="library">아이템(템플릿 + 사용자)</option>
<option value="template">템플릿 아이템</option>
<option value="user">사용자 아이템</option>
<option value="replaced-user">대체된 아이템</option>
<option value="thumbnail">썸네일 이미지</option>
<option value="avatar">프로필 이미지</option>
<option value="unused-user">미사용 아이템</option>