From 8af2726574aea572a7228f5d06bd0a5b9ca0a3da Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 19 Mar 2026 17:52:09 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v0.1.17=20?= =?UTF-8?q?=ED=8B=B0=EC=96=B4=ED=91=9C=20=EC=82=AD=EC=A0=9C=EC=99=80=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=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 | 132 +++++++++++++++++++++---- backend/src/routes/admin.js | 50 ++++++++++ backend/src/routes/tierlists.js | 10 ++ docs/history.md | 4 + docs/map.md | 8 +- docs/spec.md | 5 + docs/todo.md | 1 + docs/update.md | 4 + frontend/src/lib/api.js | 4 + frontend/src/views/AdminView.vue | 71 ++++++++++++- frontend/src/views/MyTierListsView.vue | 50 ++++++++-- 11 files changed, 305 insertions(+), 34 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 7338245..3ec2047 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -404,25 +404,38 @@ async function createCustomItem({ id, ownerId, src, label }) { return { id, ownerId, src, label, origin: 'custom', createdAt } } -async function listCustomItems({ queryText = '', page = 1, limit = 50 } = {}) { +async function getCustomItemUsageMap() { + const rows = await query('SELECT groups_json, pool_json FROM tierlists') + const usageMap = new Map() + + rows.forEach((row) => { + const groups = parseJson(row.groups_json, []) + const pool = parseJson(row.pool_json, []) + + groups.forEach((group) => { + ;(group?.itemIds || []).forEach((itemId) => { + usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1) + }) + }) + + pool.forEach((item) => { + if (item?.id) { + usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1) + } + }) + }) + + return usageMap +} + +async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) { const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) const normalizedPage = Math.max(Number(page) || 1, 1) - const offset = (normalizedPage - 1) * normalizedLimit const hasQuery = !!(queryText || '').trim() const search = `%${(queryText || '').trim()}%` 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 countRows = await query( - ` - SELECT COUNT(*) AS count - FROM custom_items c - INNER JOIN users u ON u.id = c.owner_id - ${whereClause} - `, - params - ) - const rows = await query( ` SELECT @@ -437,13 +450,13 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50 } = {}) { INNER JOIN users u ON u.id = c.owner_id ${whereClause} ORDER BY c.created_at DESC - LIMIT ? OFFSET ? `, - [...params, normalizedLimit, offset] + params ) - return { - items: rows.map((row) => ({ + const usageMap = await getCustomItemUsageMap() + const allItems = rows + .map((row) => ({ id: row.id, ownerId: row.owner_id, src: row.src, @@ -451,13 +464,61 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50 } = {}) { createdAt: Number(row.created_at), ownerName: row.nickname || row.email, ownerEmail: row.email, - })), - total: Number(countRows[0]?.count || 0), + usageCount: usageMap.get(row.id) || 0, + })) + .filter((item) => (orphanOnly ? item.usageCount === 0 : true)) + + const total = allItems.length + const offset = (normalizedPage - 1) * normalizedLimit + const pagedItems = allItems.slice(offset, offset + normalizedLimit) + + return { + items: pagedItems, + total, page: normalizedPage, limit: normalizedLimit, } } +async function findUnusedCustomItems({ queryText = '' } = {}) { + const hasQuery = !!(queryText || '').trim() + const search = `%${(queryText || '').trim()}%` + 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 rows = await query( + ` + SELECT + c.id, + c.owner_id, + c.src, + c.label, + c.created_at, + u.nickname, + u.email + FROM custom_items c + INNER JOIN users u ON u.id = c.owner_id + ${whereClause} + ORDER BY c.created_at DESC + `, + params + ) + + const usageMap = await getCustomItemUsageMap() + return rows + .map((row) => ({ + id: row.id, + ownerId: row.owner_id, + src: row.src, + label: row.label, + createdAt: Number(row.created_at), + ownerName: row.nickname || row.email, + ownerEmail: row.email, + usageCount: usageMap.get(row.id) || 0, + })) + .filter((item) => item.usageCount === 0) +} + async function listPublicTierLists(gameId) { const params = [] let whereClause = 'WHERE t.is_public = 1' @@ -531,6 +592,37 @@ async function findTierListById(id) { return mapTierListRow(rows[0]) } +async function deleteTierList(id) { + await query('DELETE FROM tierlists WHERE id = ?', [id]) +} + +async function findCustomItemsByIds(ids) { + if (!ids.length) return [] + const placeholders = ids.map(() => '?').join(', ') + const rows = await query( + ` + SELECT id, owner_id, src, label, 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), + })) +} + +async function deleteCustomItems(ids) { + if (!ids.length) return + const placeholders = ids.map(() => '?').join(', ') + await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids) +} + async function saveTierList({ id, authorId, gameId, title, description, isPublic, groups, pool }) { const existing = id ? await findTierListById(id) : null @@ -582,8 +674,12 @@ module.exports = { deleteGame, createCustomItem, listCustomItems, + findUnusedCustomItems, listPublicTierLists, listUserTierLists, findTierListById, + deleteTierList, + findCustomItemsByIds, + deleteCustomItems, saveTierList, } diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 0d2a626..047fb06 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -1,3 +1,4 @@ +const fs = require('fs/promises') const path = require('path') const express = require('express') const multer = require('multer') @@ -13,6 +14,9 @@ const { deleteGameItem, deleteGame, listCustomItems, + findUnusedCustomItems, + findCustomItemsByIds, + deleteCustomItems, listUsers, adminUpdateUser, adminUpdateUserPassword, @@ -89,6 +93,11 @@ router.get('/custom-items', requireAdmin, async (req, res) => { q: z.string().trim().max(120).optional().default(''), page: z.coerce.number().int().min(1).optional().default(1), limit: z.coerce.number().int().min(1).max(200).optional().default(50), + orphanOnly: z + .union([z.literal('true'), z.literal('false'), z.boolean()]) + .optional() + .default('false') + .transform((value) => value === true || value === 'true'), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) @@ -97,10 +106,51 @@ router.get('/custom-items', requireAdmin, async (req, res) => { queryText: parsed.data.q, page: parsed.data.page, limit: parsed.data.limit, + orphanOnly: parsed.data.orphanOnly, }) res.json(result) }) +async function removeCustomItemFiles(items) { + await Promise.all( + items.map(async (item) => { + if (!item?.src || !item.src.startsWith('/uploads/custom/')) return + const absolutePath = path.join(__dirname, '..', '..', item.src.replace(/^\//, '')) + try { + await fs.unlink(absolutePath) + } catch (e) { + if (e?.code !== 'ENOENT') throw e + } + }) + ) +} + +router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { + const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false }) + const target = result.items.find((item) => item.id === req.params.itemId) + if (!target) return res.status(404).json({ error: 'not_found' }) + if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' }) + + const items = await findCustomItemsByIds([target.id]) + await deleteCustomItems([target.id]) + await removeCustomItemFiles(items) + res.json({ ok: true }) +}) + +router.delete('/custom-items', requireAdmin, async (req, res) => { + const schema = z.object({ + q: z.string().trim().max(120).optional().default(''), + }) + const parsed = schema.safeParse(req.query) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const items = await findUnusedCustomItems({ queryText: parsed.data.q }) + const ids = items.map((item) => item.id) + await deleteCustomItems(ids) + await removeCustomItemFiles(items) + res.json({ ok: true, deletedCount: ids.length }) +}) + router.get('/users', requireAdmin, async (req, res) => { const users = await listUsers() res.json({ users }) diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 06d4ff2..a78d8ad 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -7,6 +7,7 @@ const { findTierListById, listPublicTierLists, listUserTierLists, + deleteTierList, saveTierList, createCustomItem, } = require('../db') @@ -94,6 +95,15 @@ router.get('/:id', async (req, res) => { res.json({ tierList: normalizeTierList(t) }) }) +router.delete('/:id', requireAuth, async (req, res) => { + const tierList = await findTierListById(req.params.id) + if (!tierList) return res.status(404).json({ error: 'not_found' }) + if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' }) + + await deleteTierList(tierList.id) + res.json({ ok: true }) +}) + router.post('/custom-items', requireAuth, upload.single('image'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'file_required' }) diff --git a/docs/history.md b/docs/history.md index 2088ad0..c6940e2 100644 --- a/docs/history.md +++ b/docs/history.md @@ -62,3 +62,7 @@ - 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다. - 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다. - 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다. + +## 2026-03-19 v0.1.17 +- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다. +- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다. diff --git a/docs/map.md b/docs/map.md index 176875a..bbf5bde 100644 --- a/docs/map.md +++ b/docs/map.md @@ -22,13 +22,13 @@ ## `/me` - 화면 파일: `frontend/src/views/MyTierListsView.vue` -- 역할: 내 티어표 목록 조회, 편집 화면으로 이동 -- 연동 API: `GET /api/tierlists/me` +- 역할: 내 티어표 목록 조회, 편집 화면으로 이동, 작성자 본인 티어표 삭제 +- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id` ## `/admin` - 화면 파일: `frontend/src/views/AdminView.vue` -- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일/기본 아이템 관리, 사용자 커스텀 아이템 검색/페이지네이션/다운로드, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 -- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `GET /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId` +- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일/기본 아이템 관리, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 +- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `GET /api/admin/custom-items`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId` ## `/profile` - 화면 파일: `frontend/src/views/ProfileView.vue` diff --git a/docs/spec.md b/docs/spec.md index 37af5d3..f95316a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -74,6 +74,7 @@ - `GET /api/tierlists/public` - `GET /api/tierlists/me` - `GET /api/tierlists/:id` + - `DELETE /api/tierlists/:id` - `POST /api/tierlists/custom-items` - `POST /api/tierlists` - 관리자 @@ -81,6 +82,8 @@ - `POST /api/admin/games/:gameId/thumbnail` - `POST /api/admin/games/:gameId/images` - `GET /api/admin/custom-items` + - `DELETE /api/admin/custom-items/:itemId` + - `DELETE /api/admin/custom-items` - `GET /api/admin/users` - `PATCH /api/admin/users/:userId` - `PATCH /api/admin/users/:userId/password` @@ -94,6 +97,7 @@ - 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다. - 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다. - 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다. +- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다. - 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다. ## 티어표 접근 메모 @@ -101,6 +105,7 @@ - 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다. - 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다. - 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다. +- 작성자는 `내 티어표` 목록에서 저장한 티어표를 직접 삭제할 수 있다. ## 운영 환경 변수 - 프런트엔드 diff --git a/docs/todo.md b/docs/todo.md index 2ff0a2e..a8c36c4 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -3,6 +3,7 @@ ## 즉시 확인 필요 - 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다. - 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다. +- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. diff --git a/docs/update.md b/docs/update.md index a4ea99b..9cee164 100644 --- a/docs/update.md +++ b/docs/update.md @@ -103,3 +103,7 @@ - **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선 - **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강 - **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선 + +## 2026-03-19 v0.1.17 +- **내 티어표 삭제 추가**: `내 티어표` 목록에서 작성자가 자신의 티어표를 직접 삭제할 수 있도록 삭제 버튼과 API를 추가 +- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강 diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 76359bc..a67f9dd 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -45,5 +45,9 @@ export const api = { request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`), listMyTierLists: () => request('/api/tierlists/me'), getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`), + deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }), saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }), + deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }), + deleteAdminUnusedCustomItems: ({ q = '' } = {}) => + request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }), } diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index f62bc72..d69d6bf 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -19,6 +19,7 @@ const customItemQuery = ref('') const customItemPage = ref(1) const customItemLimit = ref(50) const customItemTotal = ref(0) +const customItemOrphanOnly = ref(false) const users = ref([]) @@ -77,6 +78,7 @@ async function refreshCustomItems() { q: customItemQuery.value, page: customItemPage.value, limit: customItemLimit.value, + orphanOnly: customItemOrphanOnly.value, }) customItems.value = data.items || [] customItemTotal.value = data.total || 0 @@ -348,6 +350,11 @@ function submitCustomItemSearch() { refreshCustomItems() } +function toggleCustomItemOrphanOnly() { + customItemPage.value = 1 + refreshCustomItems() +} + function changeCustomItemLimit(limit) { customItemLimit.value = limit customItemPage.value = 1 @@ -361,6 +368,39 @@ function moveCustomItemPage(direction) { refreshCustomItems() } +async function removeCustomItem(item) { + resetMessages() + if (item.usageCount > 0) { + error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' + return + } + + const ok = window.confirm(`"${item.label}" 미사용 커스텀 이미지를 삭제할까요?`) + if (!ok) return + + try { + await api.deleteAdminCustomItem(item.id) + await refreshCustomItems() + success.value = '미사용 커스텀 이미지를 삭제했어요.' + } catch (e) { + error.value = '커스텀 이미지 삭제에 실패했어요.' + } +} + +async function removeUnusedCustomItems() { + resetMessages() + const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?') + if (!ok) return + + try { + const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value }) + await refreshCustomItems() + success.value = `${data.deletedCount || 0}개의 미사용 커스텀 이미지를 삭제했어요.` + } catch (e) { + error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.' + } +} + const displayThumbnailUrl = computed(() => { if (thumbPreviewUrl.value) return thumbPreviewUrl.value if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc) @@ -497,10 +537,20 @@ function fmt(ts) { +
+ + +
+
조건에 맞는 커스텀 아이템이 없어요.
@@ -509,8 +559,12 @@ function fmt(ts) {
{{ item.label }}
파일: {{ item.src.split('/').pop() }}
업로더: {{ item.ownerName }}
+
사용 중: {{ item.usageCount }}개 티어표
{{ fmt(item.createdAt) }}
- 이미지 다운로드 +
+ 이미지 다운로드 + +
@@ -675,6 +729,10 @@ function fmt(ts) { gap: 10px; align-items: end; } +.toolbar--secondary { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} .toolbar__search, .toolbar__select { margin-top: 0; @@ -892,6 +950,12 @@ function fmt(ts) { min-width: 0; flex: 1 1 auto; } +.customItemCard__actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 4px; +} .customItemCard__title { min-width: 0; overflow-wrap: anywhere; @@ -971,6 +1035,9 @@ function fmt(ts) { align-items: center; opacity: 0.88; } +.checkRow--toolbar { + margin-top: 0; +} @media (max-width: 980px) { .section--topGrid, .toolbar, diff --git a/frontend/src/views/MyTierListsView.vue b/frontend/src/views/MyTierListsView.vue index 9d826eb..6a19f0d 100644 --- a/frontend/src/views/MyTierListsView.vue +++ b/frontend/src/views/MyTierListsView.vue @@ -30,6 +30,18 @@ onMounted(async () => { function openList(t) { router.push(`/editor/${t.gameId}/${t.id}`) } + +async function removeList(t) { + error.value = '' + try { + const ok = window.confirm(`"${t.title}" 티어표를 삭제할까요?`) + if (!ok) return + await api.deleteTierList(t.id) + myLists.value = myLists.value.filter((entry) => entry.id !== t.id) + } catch (e) { + error.value = '티어표 삭제에 실패했어요.' + } +}