From 3bd97516219a0a74c320bf82db235dc9c916264a Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 26 Mar 2026 19:02:46 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v0.1.42=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8B=B0=EC=96=B4=ED=91=9C=20?= =?UTF-8?q?=EA=B4=80=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 | 86 ++++++- backend/src/routes/admin.js | 129 +++++++++++ backend/src/routes/tierlists.js | 5 +- docs/history.md | 5 + docs/map.md | 4 +- docs/spec.md | 7 + docs/todo.md | 5 +- docs/update.md | 6 + frontend/src/lib/api.js | 6 + frontend/src/views/AdminView.vue | 380 ++++++++++++++++++++++++++++++- 10 files changed, 625 insertions(+), 8 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 11a2833..3a7de84 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -71,6 +71,7 @@ function mapTierListRow(row) { authorAccountName: getUserAccountName(row), authorAvatarSrc: row.avatar_src || '', gameId: row.game_id, + gameName: row.game_name || '', title: row.title, thumbnailSrc: row.thumbnail_src || '', description: row.description || '', @@ -689,13 +690,44 @@ async function listUserTierLists(userId) { })) } -async function findTierListById(id) { +function uniqueTierListItems(poolItems) { + const map = new Map() + ;(poolItems || []).forEach((item) => { + if (!item?.id || map.has(item.id)) return + map.set(item.id, { + id: item.id, + src: item.src || '', + label: item.label || 'item', + origin: item.origin || 'game', + }) + }) + return Array.from(map.values()) +} + +async function listAdminTierLists({ queryText = '', page = 1, limit = 50 } = {}) { + const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) + const normalizedPage = Math.max(Number(page) || 1, 1) + const hasQuery = !!(queryText || '').trim() + const search = `%${(queryText || '').trim()}%` + const whereClause = hasQuery + ? ` + WHERE + t.title LIKE ? + OR g.name LIKE ? + OR g.id LIKE ? + OR u.email LIKE ? + OR u.nickname LIKE ? + ` + : '' + const params = hasQuery ? [search, search, search, search, search] : [] + const rows = await query( ` SELECT t.id, t.author_id, t.game_id, + g.name AS game_name, t.title, t.thumbnail_src, t.description, @@ -709,6 +741,57 @@ async function findTierListById(id) { u.avatar_src FROM tierlists t INNER JOIN users u ON u.id = t.author_id + INNER JOIN games g ON g.id = t.game_id + ${whereClause} + ORDER BY t.updated_at DESC, t.created_at DESC + `, + params + ) + + const allItems = rows.map((row) => { + const tierList = mapTierListRow(row) + const poolItems = uniqueTierListItems(tierList.pool) + const extraItems = poolItems.filter((item) => item.origin === 'custom') + return { + ...tierList, + itemCount: poolItems.length, + extraItemCount: extraItems.length, + extraItems, + } + }) + + const total = allItems.length + const offset = (normalizedPage - 1) * normalizedLimit + return { + tierLists: allItems.slice(offset, offset + normalizedLimit), + total, + page: normalizedPage, + limit: normalizedLimit, + } +} + +async function findTierListById(id) { + const rows = await query( + ` + SELECT + t.id, + t.author_id, + t.game_id, + g.name AS game_name, + t.title, + t.thumbnail_src, + t.description, + t.is_public, + t.groups_json, + t.pool_json, + t.created_at, + t.updated_at, + u.nickname, + u.email, + u.avatar_src + FROM tierlists t + INNER JOIN users u ON u.id = t.author_id + INNER JOIN games g ON g.id = t.game_id WHERE t.id = ? LIMIT 1 `, @@ -805,6 +888,7 @@ module.exports = { findUnusedCustomItems, listPublicTierLists, listUserTierLists, + listAdminTierLists, findTierListById, deleteTierList, findCustomItemsByIds, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index c733f5d..f72a594 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -22,6 +22,8 @@ const { findCustomItemsByIds, deleteCustomItems, listUsers, + listAdminTierLists, + findTierListById, adminUpdateUser, adminUpdateUserPassword, adminDeleteUser, @@ -161,6 +163,23 @@ router.get('/custom-items', requireAdmin, async (req, res) => { res.json(result) }) +router.get('/tierlists', requireAdmin, async (req, res) => { + const schema = z.object({ + 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), + }) + const parsed = schema.safeParse(req.query) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const result = await listAdminTierLists({ + queryText: parsed.data.q, + page: parsed.data.page, + limit: parsed.data.limit, + }) + res.json(result) +}) + async function removeCustomItemFiles(items) { await Promise.all( items.map(async (item) => { @@ -192,6 +211,72 @@ async function promoteCustomItemToGameItem({ customItem, gameId }) { }) } +async function copyUploadIntoGameAsset(src) { + if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || '' + + const originalName = path.basename(src) + const nextFilename = buildUploadFilename({ originalname: originalName }) + const sourcePath = path.join(__dirname, '..', '..', src.replace(/^\//, '')) + const targetRelativePath = path.join('uploads', 'games', nextFilename) + const targetPath = path.join(__dirname, '..', '..', targetRelativePath) + + await fs.copyFile(sourcePath, targetPath) + return `/${targetRelativePath.replace(/\\/g, '/')}` +} + +function uniqueTierListPoolItems(tierList) { + const seen = new Set() + return (tierList?.pool || []).filter((item) => { + if (!item?.id || seen.has(item.id)) return false + seen.add(item.id) + return true + }) +} + +async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) { + const allowedIds = new Set((itemIds || []).filter(Boolean)) + const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom') + const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems + const createdItems = [] + + for (const item of itemsToCopy) { + const copiedSrc = await copyUploadIntoGameAsset(item.src) + createdItems.push( + await createGameItem({ + id: nanoid(), + gameId, + src: copiedSrc, + label: item.label, + }) + ) + } + + return createdItems +} + +async function createGameTemplateFromTierList({ tierList, gameId, gameName }) { + await createGame({ id: gameId, name: gameName }) + if (tierList.thumbnailSrc) { + const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc) + await updateGameThumbnail(gameId, copiedThumb) + } + + const createdItems = [] + for (const item of uniqueTierListPoolItems(tierList)) { + const copiedSrc = await copyUploadIntoGameAsset(item.src) + createdItems.push( + await createGameItem({ + id: nanoid(), + gameId, + src: copiedSrc, + label: item.label, + }) + ) + } + + return { game: await findGameById(gameId), items: createdItems } +} + 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) @@ -221,6 +306,50 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => { res.json({ item }) }) +router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => { + const schema = z.object({ + gameId: z.string().min(1), + itemIds: z.array(z.string().min(1)).optional().default([]), + }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const game = await findGameById(parsed.data.gameId) + if (!game) return res.status(404).json({ error: 'game_not_found' }) + + const tierList = await findTierListById(req.params.tierListId) + if (!tierList) return res.status(404).json({ error: 'not_found' }) + + const items = await promoteTierListItemsToGame({ + tierList, + gameId: game.id, + itemIds: parsed.data.itemIds, + }) + res.json({ items }) +}) + +router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => { + const schema = z.object({ + gameId: z.string().trim().min(1).max(120), + name: z.string().trim().min(1).max(120), + }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const exists = await findGameById(parsed.data.gameId) + if (exists) return res.status(409).json({ error: 'game_id_taken' }) + + const tierList = await findTierListById(req.params.tierListId) + if (!tierList) return res.status(404).json({ error: 'not_found' }) + + const result = await createGameTemplateFromTierList({ + tierList, + gameId: parsed.data.gameId, + gameName: parsed.data.name, + }) + res.json(result) +}) + router.delete('/custom-items', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index db16017..d6ade02 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -10,6 +10,7 @@ const { deleteTierList, saveTierList, createCustomItem, + findUserById, } = require('../db') const { requireAuth } = require('../middleware/auth') @@ -99,7 +100,9 @@ router.get('/:id', async (req, res) => { const t = await findTierListById(req.params.id) if (!t) return res.status(404).json({ error: 'not_found' }) if (!t.isPublic) { - if (!req.session || req.session.userId !== t.authorId) return res.status(403).json({ error: 'forbidden' }) + if (!req.session?.userId) return res.status(403).json({ error: 'forbidden' }) + const currentUser = req.session.userId === t.authorId ? { isAdmin: false } : await findUserById(req.session.userId) + if (req.session.userId !== t.authorId && !currentUser?.isAdmin) return res.status(403).json({ error: 'forbidden' }) } res.json({ tierList: normalizeTierList(t) }) }) diff --git a/docs/history.md b/docs/history.md index eb40684..3dba9fd 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-03-26 v0.1.42 +- 관리자 운영 관점에서는 공개 목록만으로는 부족하므로, 전체 티어표를 검색하고 추가 아이템까지 확인하는 별도 `티어표 관리` 탭을 두는 편이 더 적합하다고 정리했다. +- 게임 기반 티어표의 “사용자 추가 아이템”과 `freeform` 티어표의 “전체 아이템”은 활용 목적이 다르므로, 전자는 기존 게임 템플릿 승격 중심으로, 후자는 새 게임 템플릿 생성 중심으로 다루기로 결정했다. +- 관리자는 moderation 목적의 완성본 검토가 필요하므로, 작성자가 아니어도 비공개 티어표 상세를 열람할 수 있게 하기로 했다. + ## 2026-03-26 v0.1.41 - 관리자 커스텀 아이템 승격은 버튼만 보이는 상태로 끝나면 안 되므로, 프런트 API와 백엔드 라우트가 실제로 함께 연결되어야 기능이 완결된다고 정리했다. diff --git a/docs/map.md b/docs/map.md index 1255ff8..49a53f0 100644 --- a/docs/map.md +++ b/docs/map.md @@ -27,8 +27,8 @@ ## `/admin` - 화면 파일: `frontend/src/views/AdminView.vue` -- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 -- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `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` +- 역할: `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템 승격, freeform 티어표의 게임 템플릿화, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 +- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `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 f1bbb4e..07176e5 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -88,6 +88,9 @@ - `POST /api/admin/games/:gameId/images` - 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다. - `PATCH /api/admin/games/:gameId/items/:itemId` + - `GET /api/admin/tierlists` + - `POST /api/admin/tierlists/:tierListId/promote-items` + - `POST /api/admin/tierlists/:tierListId/create-game-template` - `GET /api/admin/custom-items` - `POST /api/admin/custom-items/:itemId/promote` - `DELETE /api/admin/custom-items/:itemId` @@ -110,11 +113,15 @@ - 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다. - 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다. - 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다. +- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 최근 티어표 전체를 제목/게임/작성자 기준으로 검색하고 공개 여부를 함께 확인할 수 있다. +- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다. +- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다. - 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다. ## 티어표 접근 메모 - `new` 작성 경로는 로그인한 사용자만 진입할 수 있다. - 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다. +- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다. - 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다. - 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다. - 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다. diff --git a/docs/todo.md b/docs/todo.md index 9e51bed..259a426 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,13 +1,14 @@ # 할 일 및 이슈 ## 즉시 확인 필요 -- 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다. - 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다. - 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다. - 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다. - 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. - 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다. - 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. +- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다. +- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. @@ -19,6 +20,6 @@ ## 중기 개선 - 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다. - 자동 테스트와 최소한의 배포 체크리스트를 만든다. -- 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다. +- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다. - 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다. - 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다. diff --git a/docs/update.md b/docs/update.md index b0e3571..a272311 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 2026-03-26 v0.1.42 +- **관리자 티어표 관리 탭 추가**: 공개/비공개를 포함한 최근 티어표 전체를 관리자 화면에서 검색/페이지네이션으로 확인하고, 제목·작성자·게임·공개 여부를 함께 볼 수 있도록 보강 +- **추가 아이템 승격 흐름 확장**: 티어표 안에서 사용자가 추가한 커스텀 아이템을 관리자 화면에서 바로 특정 게임의 기본 템플릿으로 개별 또는 일괄 복제할 수 있도록 추가 +- **커스텀 티어표 템플릿화 추가**: `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 별도 게임 템플릿으로 복제 생성할 수 있도록 지원 +- **관리자 열람 권한 확장**: 비공개 티어표도 관리자는 편집 화면에서 완성본을 열람할 수 있도록 상세 조회 권한을 확장 + ## 2026-03-26 v0.1.41 - **커스텀 아이템 승격 연결 수정**: 관리자 아이템 관리의 `기본 템플릿에 추가` 버튼이 실제 API와 백엔드 승격 라우트로 연결되도록 누락된 프런트/백엔드 구현을 보완 diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 92fd7be..3f35157 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -39,8 +39,14 @@ export const api = { request( `/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}` ), + listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) => + request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`), promoteAdminCustomItem: (itemId, payload) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }), + promoteAdminTierListItems: (tierListId, payload) => + request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }), + createAdminGameTemplateFromTierList: (tierListId, payload) => + request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }), listAdminUsers: () => request('/api/admin/users'), updateAdminUser: (userId, payload) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }), diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 46c92e2..61b736e 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1,10 +1,12 @@