Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d5506e35a | |||
| 8b3d469503 | |||
| a2fc8f8cd4 |
@@ -1152,6 +1152,21 @@ function replaceItemSrc(items, fromSrc, toSrc, toLabel = '') {
|
|||||||
return { changed, items: nextItems }
|
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 }) {
|
async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '', updateCustomItemsBySrc = true }) {
|
||||||
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
|
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
|
||||||
const normalizedLabel = typeof toLabel === 'string' ? toLabel.trim().slice(0, 60) : ''
|
const normalizedLabel = typeof toLabel === 'string' ? toLabel.trim().slice(0, 60) : ''
|
||||||
@@ -1222,6 +1237,40 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '', upd
|
|||||||
return { updatedRows }
|
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() {
|
async function listImageAssets() {
|
||||||
const rows = await query(
|
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'
|
'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)
|
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) {
|
async function updateImageAssetLabel(assetId, label) {
|
||||||
await query('UPDATE image_assets SET label_override = ? WHERE id = ?', [label, assetId])
|
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])
|
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])
|
||||||
@@ -1840,7 +1897,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
src: row.src,
|
src: row.src,
|
||||||
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
|
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
|
||||||
createdAt: Number(row.created_at || 0),
|
createdAt: Number(row.created_at || 0),
|
||||||
ownerName: '관리자 보관 자산',
|
ownerName: '관리자 미사용 이미지',
|
||||||
ownerEmail: '',
|
ownerEmail: '',
|
||||||
usageCount: 0,
|
usageCount: 0,
|
||||||
linkedTemplates: [],
|
linkedTemplates: [],
|
||||||
@@ -1940,8 +1997,12 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
return item.assetKind === 'avatar'
|
return item.assetKind === 'avatar'
|
||||||
case 'library':
|
case 'library':
|
||||||
return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
|
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':
|
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':
|
case 'unused-admin':
|
||||||
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
|
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
|
||||||
default:
|
default:
|
||||||
@@ -1968,37 +2029,57 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
|
|||||||
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
|
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
|
||||||
const params = hasQuery ? [search, search, search, search] : []
|
const params = hasQuery ? [search, search, search, search] : []
|
||||||
|
|
||||||
const rows = await query(
|
const [rows, topicItemRows, usageMeta] = await Promise.all([
|
||||||
`
|
query(
|
||||||
SELECT
|
`
|
||||||
c.id,
|
SELECT
|
||||||
c.owner_id,
|
c.id,
|
||||||
c.src,
|
c.owner_id,
|
||||||
c.label,
|
c.src,
|
||||||
c.replaced_by_item_id,
|
c.label,
|
||||||
c.replaced_by_src,
|
c.replaced_by_item_id,
|
||||||
c.replaced_by_label,
|
c.replaced_by_src,
|
||||||
c.replaced_at,
|
c.replaced_by_label,
|
||||||
c.created_at,
|
c.replaced_at,
|
||||||
u.nickname,
|
c.created_at,
|
||||||
u.email
|
u.nickname,
|
||||||
FROM custom_items c
|
u.email
|
||||||
INNER JOIN users u ON u.id = c.owner_id
|
FROM custom_items c
|
||||||
${whereClause}
|
INNER JOIN users u ON u.id = c.owner_id
|
||||||
ORDER BY c.created_at DESC
|
${whereClause}
|
||||||
`,
|
ORDER BY c.created_at DESC
|
||||||
params
|
`,
|
||||||
)
|
params
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`
|
||||||
|
SELECT ti.topic_id, tp.name AS topic_name, ti.src
|
||||||
|
FROM topic_items ti
|
||||||
|
LEFT JOIN topics tp ON tp.id = ti.topic_id
|
||||||
|
`
|
||||||
|
),
|
||||||
|
getCustomItemUsageMeta(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const templateLinkedBySrc = new Map()
|
||||||
|
topicItemRows.forEach((row) => {
|
||||||
|
if (!row?.src) return
|
||||||
|
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
|
||||||
|
templateLinkedBySrc.get(row.src).set(row.topic_id, {
|
||||||
|
id: row.topic_id,
|
||||||
|
name: row.topic_name || row.topic_id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const { usageMap } = await getCustomItemUsageMeta()
|
|
||||||
return rows
|
return rows
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
...mapCustomItemRow(row),
|
...mapCustomItemRow(row),
|
||||||
ownerName: row.nickname || row.email,
|
ownerName: row.nickname || row.email,
|
||||||
ownerEmail: row.email,
|
ownerEmail: row.email,
|
||||||
usageCount: usageMap.get(row.id) || 0,
|
usageCount: usageMeta.usageMap.get(row.id) || 0,
|
||||||
|
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||||
}))
|
}))
|
||||||
.filter((item) => item.usageCount === 0)
|
.filter((item) => ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFavoriteStatsForTierListIds(tierListIds, userId = '') {
|
async function getFavoriteStatsForTierListIds(tierListIds, userId = '') {
|
||||||
@@ -3075,6 +3156,7 @@ module.exports = {
|
|||||||
listReferencedUploadSources,
|
listReferencedUploadSources,
|
||||||
listReferencedUploadUsage,
|
listReferencedUploadUsage,
|
||||||
replaceUploadSourceReferences,
|
replaceUploadSourceReferences,
|
||||||
|
updateCustomItemDisplayReferences,
|
||||||
clearImageOptimizationJobs,
|
clearImageOptimizationJobs,
|
||||||
getImageAssetStats,
|
getImageAssetStats,
|
||||||
cleanupMissingUploadReferences,
|
cleanupMissingUploadReferences,
|
||||||
@@ -3086,6 +3168,7 @@ module.exports = {
|
|||||||
deleteTopic,
|
deleteTopic,
|
||||||
updateTopicDisplayOrder,
|
updateTopicDisplayOrder,
|
||||||
updateCustomItemLabel,
|
updateCustomItemLabel,
|
||||||
|
clearCustomItemReplacement,
|
||||||
markCustomItemReplaced,
|
markCustomItemReplaced,
|
||||||
updateImageAssetLabel,
|
updateImageAssetLabel,
|
||||||
createCustomItem,
|
createCustomItem,
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ const {
|
|||||||
listRecentImageOptimizationJobs,
|
listRecentImageOptimizationJobs,
|
||||||
clearImageOptimizationJobs,
|
clearImageOptimizationJobs,
|
||||||
cleanupMissingUploadReferences,
|
cleanupMissingUploadReferences,
|
||||||
|
listReferencedUploadSources,
|
||||||
replaceUploadSourceReferences,
|
replaceUploadSourceReferences,
|
||||||
|
updateCustomItemDisplayReferences,
|
||||||
|
clearCustomItemReplacement,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAdmin } = require('../middleware/auth')
|
const { requireAdmin } = require('../middleware/auth')
|
||||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||||
@@ -358,7 +361,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
|||||||
page: z.coerce.number().int().min(1).optional().default(1),
|
page: z.coerce.number().int().min(1).optional().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||||
filter: z
|
filter: z
|
||||||
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'unused-admin'])
|
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused', 'unused-user', 'replaced-user', 'unused-admin'])
|
||||||
.optional()
|
.optional()
|
||||||
.default('library'),
|
.default('library'),
|
||||||
})
|
})
|
||||||
@@ -544,6 +547,12 @@ async function removeCustomItemFiles(items) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeUnreferencedCustomItemFiles(items) {
|
||||||
|
const referencedSrcs = new Set(await listReferencedUploadSources())
|
||||||
|
const removableItems = (items || []).filter((item) => item?.src && !referencedSrcs.has(item.src))
|
||||||
|
await removeCustomItemFiles(removableItems)
|
||||||
|
}
|
||||||
|
|
||||||
async function promoteLibraryItemToTemplateItem({ item, templateId }) {
|
async function promoteLibraryItemToTemplateItem({ item, templateId }) {
|
||||||
return createTopicItem({
|
return createTopicItem({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
@@ -774,13 +783,14 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
|||||||
return res.json({ ok: true, sourceType: 'template' })
|
return res.json({ ok: true, sourceType: 'template' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canDeleteReplacedUserItem = target.sourceType === 'user' && !!target.replacedAt
|
||||||
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
|
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
|
||||||
if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
|
if (!canDeleteReplacedUserItem && target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
|
||||||
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
|
if (!canDeleteReplacedUserItem && target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
|
||||||
|
|
||||||
const items = await findCustomItemsByIds([target.id])
|
const items = await findCustomItemsByIds([target.id])
|
||||||
await deleteCustomItems([target.id])
|
await deleteCustomItems([target.id])
|
||||||
await removeCustomItemFiles(items)
|
await removeUnreferencedCustomItemFiles(items)
|
||||||
res.json({ ok: true, sourceType: 'user' })
|
res.json({ ok: true, sourceType: 'user' })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -837,6 +847,11 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
|
|||||||
toLabel: targetItem.label || '',
|
toLabel: targetItem.label || '',
|
||||||
updateCustomItemsBySrc: false,
|
updateCustomItemsBySrc: false,
|
||||||
})
|
})
|
||||||
|
const displayResult = await updateCustomItemDisplayReferences({
|
||||||
|
itemId: sourceItem.id,
|
||||||
|
src: targetItem.src,
|
||||||
|
label: targetItem.label || '',
|
||||||
|
})
|
||||||
await markCustomItemReplaced({
|
await markCustomItemReplaced({
|
||||||
itemId: sourceItem.id,
|
itemId: sourceItem.id,
|
||||||
replacedByItemId: targetItem.id || '',
|
replacedByItemId: targetItem.id || '',
|
||||||
@@ -846,12 +861,31 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
updatedRows: result.updatedRows || 0,
|
updatedRows: (result.updatedRows || 0) + (displayResult.updatedRows || 0),
|
||||||
sourceItem,
|
sourceItem,
|
||||||
targetItem,
|
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) => {
|
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
topicId: z.string().min(1),
|
topicId: z.string().min(1),
|
||||||
@@ -1106,11 +1140,27 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
|
|||||||
const parsed = schema.safeParse(req.query)
|
const parsed = schema.safeParse(req.query)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
const items = await findUnusedCustomItems({ queryText: parsed.data.q })
|
const result = await listCustomItems({
|
||||||
const ids = items.map((item) => item.id)
|
queryText: parsed.data.q,
|
||||||
await deleteCustomItems(ids)
|
page: 1,
|
||||||
await removeCustomItemFiles(items)
|
limit: 10000,
|
||||||
res.json({ ok: true, deletedCount: ids.length })
|
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) => {
|
router.get('/users', requireAdmin, async (req, res) => {
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.85
|
||||||
|
- 썸네일과 프로필 이미지는 이미 별도 필터로 분리돼 있고, 템플릿 아이템/사용자 아이템도 각각 구분되고 있으므로, 그 어디에도 속하지 않는 관리자 업로드 자산은 운영 의미상 `보관 자산`보다 `미사용 이미지`로 보는 편이 더 직관적이라고 정리했다.
|
||||||
|
- 또 관리자 아이템 화면에서 삭제 버튼이 필터 조건에 따라 갑자기 비활성으로만 보이면 흐름을 이해하기 어려우므로, 평소에는 `미사용 이미지 확인`, 해당 화면 안에서만 `미사용 이미지 일괄 삭제`로 바뀌는 점진적 동작이 더 낫다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.84
|
||||||
|
- 대체된 원본 아이템은 운영상 이미 교체 완료된 이력이므로, 현재 저장본에서 같은 item id 사용량이 남아 있더라도 개별 삭제를 막아 두는 쪽보다 명시적으로 정리 가능하게 두는 편이 더 맞다고 판단했다.
|
||||||
|
- 또 관리자 목록에서 보이는 `미사용` 기준과 실제 일괄 삭제 API 기준이 다르면 운영자가 버튼 상태를 신뢰하기 어려워지므로, 둘은 반드시 같은 조건으로 맞추기로 정리했다.
|
||||||
|
- 마지막으로 업로드 파일은 하나의 레코드가 없어졌다고 곧바로 지우기보다, 전체 참조를 다시 확인한 뒤 정말 고아 파일일 때만 삭제하는 방식이 더 안전하다고 판단했다.
|
||||||
|
|
||||||
|
## 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
|
## 2026-04-06 v1.4.81
|
||||||
- 원본 보존을 하더라도 실제 `custom_items.src`와 `label`까지 대체 대상 값으로 덮어쓰면 관리자 입장에서는 “원본을 남겼다”기보다 “같은 새 이미지가 두 번 보이는 상태”로 읽히므로, 원본 아이템 레코드는 실제 이미지/라벨을 그대로 유지하고 참조 이동 대상에서만 제외하는 편이 맞다고 정리했다.
|
- 원본 보존을 하더라도 실제 `custom_items.src`와 `label`까지 대체 대상 값으로 덮어쓰면 관리자 입장에서는 “원본을 남겼다”기보다 “같은 새 이미지가 두 번 보이는 상태”로 읽히므로, 원본 아이템 레코드는 실제 이미지/라벨을 그대로 유지하고 참조 이동 대상에서만 제외하는 편이 맞다고 정리했다.
|
||||||
- 즉 대체 이력 메타(`어떤 아이템으로 대체됐는지`)와 원본 본문 데이터(`원래 A 이미지/이름`)는 분리해서 보존해야, 이후 복구나 검수 기준으로 의미가 살아난다고 판단했다.
|
- 즉 대체 이력 메타(`어떤 아이템으로 대체됐는지`)와 원본 본문 데이터(`원래 A 이미지/이름`)는 분리해서 보존해야, 이후 복구나 검수 기준으로 의미가 살아난다고 판단했다.
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 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개이고 템플릿 연결도 없을 때만 일반 미사용으로 잡히고, 대체된 아이템은 별도 예외로 계속 정리 대상에 포함된다.
|
||||||
|
- 커스텀 이미지 레코드를 지운 뒤 실제 업로드 파일을 정리할 때는, 다른 곳에서 같은 파일 경로를 아직 참조 중이면 파일은 남겨 두도록 안전장치를 추가했다.
|
||||||
|
|
||||||
|
## 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
|
## 2026-04-06 v1.4.81
|
||||||
- 관리자 이미지 대체 시 원본 사용자 아이템 레코드의 `src / label`까지 대체 대상 이미지로 덮어써져, 카드와 상세 모달에서 원래 A 이미지 정보를 다시 볼 수 없던 문제를 바로잡았다.
|
- 관리자 이미지 대체 시 원본 사용자 아이템 레코드의 `src / label`까지 대체 대상 이미지로 덮어써져, 카드와 상세 모달에서 원래 A 이미지 정보를 다시 볼 수 없던 문제를 바로잡았다.
|
||||||
- 이제 대체 처리에서는 티어표/요청/템플릿 참조만 새 이미지로 옮기고, 원본 사용자 아이템 레코드는 원래 이미지와 이름을 그대로 유지한 채 `대체됨` 메타만 남긴다.
|
- 이제 대체 처리에서는 티어표/요청/템플릿 참조만 새 이미지로 옮기고, 원본 사용자 아이템 레코드는 원래 이미지와 이름을 그대로 유지한 채 `대체됨` 메타만 남긴다.
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function useAdminCustomItems({
|
|||||||
|
|
||||||
function openCustomItemDeleteModal(item) {
|
function openCustomItemDeleteModal(item) {
|
||||||
if (!item) return
|
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 = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ export function useAdminCustomItems({
|
|||||||
async function removeCustomItem(item = modalTargetCustomItem.value) {
|
async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
if (!item) return
|
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 = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -175,18 +175,26 @@ export function useAdminCustomItems({
|
|||||||
|
|
||||||
async function removeUnusedCustomItems() {
|
async function removeUnusedCustomItems() {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?')
|
const ok = window.confirm('현재 검색 조건에 맞는 미사용 이미지를 모두 삭제할까요?')
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
|
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
|
||||||
await refreshCustomItems()
|
await refreshCustomItems()
|
||||||
success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.`
|
success.value = `${data.deletedCount || 0}개의 미사용 이미지를 삭제했어요.`
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.'
|
error.value = '미사용 이미지 일괄 삭제에 실패했어요.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showUnusedCustomItems() {
|
||||||
|
if (customItemFilter.value === 'unused') return
|
||||||
|
resetMessages()
|
||||||
|
customItemFilter.value = 'unused'
|
||||||
|
customItemPage.value = 1
|
||||||
|
refreshCustomItems()
|
||||||
|
}
|
||||||
|
|
||||||
async function saveCustomItemModalLabel() {
|
async function saveCustomItemModalLabel() {
|
||||||
const item = modalTargetCustomItem.value
|
const item = modalTargetCustomItem.value
|
||||||
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
|
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
|
||||||
@@ -257,6 +265,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 {
|
return {
|
||||||
submitCustomItemSearch,
|
submitCustomItemSearch,
|
||||||
changeCustomItemFilter,
|
changeCustomItemFilter,
|
||||||
@@ -270,9 +299,11 @@ export function useAdminCustomItems({
|
|||||||
jumpToTemplateAdmin,
|
jumpToTemplateAdmin,
|
||||||
removeCustomItem,
|
removeCustomItem,
|
||||||
removeUnusedCustomItems,
|
removeUnusedCustomItems,
|
||||||
|
showUnusedCustomItems,
|
||||||
saveCustomItemModalLabel,
|
saveCustomItemModalLabel,
|
||||||
promoteCustomItem,
|
promoteCustomItem,
|
||||||
refreshReplacementCandidates,
|
refreshReplacementCandidates,
|
||||||
replaceCustomItem,
|
replaceCustomItem,
|
||||||
|
restoreCustomItem,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export const api = {
|
|||||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||||
replaceAdminCustomItem: (itemId, payload) =>
|
replaceAdminCustomItem: (itemId, payload) =>
|
||||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/replace`, { method: 'POST', body: 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) =>
|
updateAdminCustomItemLabel: (itemId, payload) =>
|
||||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
||||||
promoteAdminTierListItems: (tierListId, payload) =>
|
promoteAdminTierListItems: (tierListId, payload) =>
|
||||||
|
|||||||
@@ -657,6 +657,16 @@ const visibleLinkedTemplates = computed(() =>
|
|||||||
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
|
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
|
||||||
const canReplaceModalTarget = computed(() => modalTargetCustomItem.value?.sourceType === 'user')
|
const canReplaceModalTarget = computed(() => modalTargetCustomItem.value?.sourceType === 'user')
|
||||||
const replacementCandidateCount = computed(() => customItemReplacementItems.value.length)
|
const replacementCandidateCount = computed(() => customItemReplacementItems.value.length)
|
||||||
|
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 === 'asset' ||
|
||||||
|
!!item?.isAssetLibraryItem
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
||||||
const imageStatsYearOptions = computed(() => {
|
const imageStatsYearOptions = computed(() => {
|
||||||
@@ -1049,10 +1059,12 @@ const {
|
|||||||
jumpToTemplateAdmin,
|
jumpToTemplateAdmin,
|
||||||
removeCustomItem,
|
removeCustomItem,
|
||||||
removeUnusedCustomItems,
|
removeUnusedCustomItems,
|
||||||
|
showUnusedCustomItems,
|
||||||
saveCustomItemModalLabel,
|
saveCustomItemModalLabel,
|
||||||
promoteCustomItem,
|
promoteCustomItem,
|
||||||
refreshReplacementCandidates,
|
refreshReplacementCandidates,
|
||||||
replaceCustomItem,
|
replaceCustomItem,
|
||||||
|
restoreCustomItem,
|
||||||
} = useAdminCustomItems({
|
} = useAdminCustomItems({
|
||||||
api,
|
api,
|
||||||
toast,
|
toast,
|
||||||
@@ -2200,7 +2212,10 @@ function openUserProfile(user) {
|
|||||||
<button v-if="canReplaceModalTarget" class="btn btn--primary customItemModal__action" :disabled="!customItemReplacementTargetId || customItemReplacementBusy" @click="replaceCustomItem(modalTargetCustomItem)">
|
<button v-if="canReplaceModalTarget" class="btn btn--primary customItemModal__action" :disabled="!customItemReplacementTargetId || customItemReplacementBusy" @click="replaceCustomItem(modalTargetCustomItem)">
|
||||||
{{ customItemReplacementBusy ? '대체중...' : '선택한 이미지로 대체' }}
|
{{ customItemReplacementBusy ? '대체중...' : '선택한 이미지로 대체' }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2442,14 +2457,23 @@ function openUserProfile(user) {
|
|||||||
<option value="library">아이템(템플릿 + 사용자)</option>
|
<option value="library">아이템(템플릿 + 사용자)</option>
|
||||||
<option value="template">템플릿 아이템</option>
|
<option value="template">템플릿 아이템</option>
|
||||||
<option value="user">사용자 아이템</option>
|
<option value="user">사용자 아이템</option>
|
||||||
|
<option value="replaced-user">대체된 아이템</option>
|
||||||
<option value="thumbnail">썸네일 이미지</option>
|
<option value="thumbnail">썸네일 이미지</option>
|
||||||
<option value="avatar">프로필 이미지</option>
|
<option value="avatar">프로필 이미지</option>
|
||||||
<option value="unused-user">미사용 아이템</option>
|
<option value="unused">미사용 이미지</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="adminSidebar__actions">
|
<div class="adminSidebar__actions">
|
||||||
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
||||||
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 아이템 일괄 삭제</button>
|
<button
|
||||||
|
v-if="customItemFilter === 'unused'"
|
||||||
|
class="btn btn--danger"
|
||||||
|
:disabled="!hasDeletableUnusedItems"
|
||||||
|
@click="removeUnusedCustomItems"
|
||||||
|
>
|
||||||
|
미사용 이미지 일괄 삭제
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn btn--ghost" @click="showUnusedCustomItems">미사용 이미지 확인</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="adminSidebar__stats">
|
<div class="adminSidebar__stats">
|
||||||
<div class="sidebarStat">
|
<div class="sidebarStat">
|
||||||
|
|||||||
Reference in New Issue
Block a user