From 66408aaa1bb46cc277ba5ae0645f30fb3db084fe Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 3 Apr 2026 13:40:08 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=ED=95=84=ED=84=B0=EC=99=80=20=EB=AF=B8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=B6=84=EB=A5=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 19 ++++++++++++++++--- backend/src/routes/admin.js | 5 ++++- docs/history.md | 4 ++++ docs/spec.md | 11 ++++++----- docs/todo.md | 8 +++++--- docs/update.md | 6 ++++++ frontend/src/views/AdminView.vue | 20 ++++++++++---------- 7 files changed, 51 insertions(+), 22 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index b309fda..df1f1b4 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -205,10 +205,17 @@ function getAssetLibrarySourceLabel(src) { const normalizedSrc = String(src || '').trim() if (normalizedSrc.includes('/uploads/assets/avatars/')) return '프로필 아바타' if (normalizedSrc.includes('/uploads/assets/tierlists/')) return '티어표 썸네일' - if (normalizedSrc.includes('/uploads/assets/topics/')) return '템플릿 썸네일' + if (normalizedSrc.includes('/uploads/assets/topics/')) return '썸네일 이미지' return '보관 자산' } +function getAssetLibraryKind(src) { + const normalizedSrc = String(src || '').trim() + if (normalizedSrc.includes('/uploads/assets/avatars/')) return 'avatar' + if (normalizedSrc.includes('/uploads/assets/tierlists/') || normalizedSrc.includes('/uploads/assets/topics/')) return 'thumbnail' + return 'asset' +} + async function createPool() { const rootConnection = await mysql.createConnection({ host: DB_HOST, @@ -1845,7 +1852,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod usageCount: usageMeta.usageMap.get(row.id) || 0, linkedTemplates, sourceType: 'user', - sourceLabel: '사용자 업로드', + sourceLabel: '사용자 아이템', canDelete: true, } }) @@ -1867,6 +1874,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod linkedTemplates: [], sourceType: 'asset', sourceLabel: getAssetLibrarySourceLabel(row.src), + assetKind: getAssetLibraryKind(row.src), canDelete: true, sourceTopicId: '', sourceTopicName: '', @@ -1884,7 +1892,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size, linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), sourceType: 'template', - sourceLabel: '관리자 템플릿', + sourceLabel: '템플릿 아이템', canDelete: true, sourceTopicId: row.topic_id, sourceTopicName: row.topic_name || row.topic_id, @@ -1930,6 +1938,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod label: entry.label, sourceLabel: entry.sourceLabel, sourceType: entry.sourceType, + assetKind: entry.assetKind || '', ownerName: entry.ownerName, createdAt: entry.createdAt, sourceTopicId: entry.sourceTopicId || '', @@ -1948,6 +1957,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod return item.sourceType === 'template' && !item.isAssetLibraryItem case 'asset': return item.sourceType === 'asset' || !!item.isAssetLibraryItem + case 'thumbnail': + return item.sourceType === 'asset' && item.assetKind === 'thumbnail' + case 'avatar': + return item.sourceType === 'asset' && item.assetKind === 'avatar' case 'library': return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem) case 'unused-user': diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index e4090fa..92db87e 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -332,7 +332,10 @@ 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), - filter: z.enum(['library', 'all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('library'), + filter: z + .enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'unused-admin']) + .optional() + .default('library'), }) const parsed = schema.safeParse(req.query) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) diff --git a/docs/history.md b/docs/history.md index 2cc5e08..8b9cb43 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-03 v1.4.57 +- 아이템 관리 필터는 전체 데이터 탐색과 실제 반복 아이템 검수를 같은 셀렉트에서 오가야 하므로, `전체 이미지`를 맨 위에 두되 기본값은 여전히 `아이템(템플릿 + 사용자)`로 유지해 운영자가 처음부터 프로필/썸네일 자산에 묻히지 않게 하는 편이 맞다고 판단했다. +- `미사용 사용자 업로드`라는 표현은 계정 탈퇴 잔여물처럼 오해될 수 있으므로, 실제 의미가 “사용자 아이템 레코드는 남아 있지만 현재 저장 티어표/템플릿 참조가 없는 항목”이라는 점에 맞춰 `미사용 아이템`으로 줄이고, 계정 삭제 시 외래키로 같이 삭제되는 항목은 이 범주로 남지 않는다고 정리했다. + ## 2026-04-03 v1.4.56 - 아이템 관리의 원래 목적은 “반복 사용 가능한 티어표 아이템”과 “사용자가 올린 커스텀 아이템”을 운영자가 구분해 검수하는 것이었는데, 프로필 아바타나 티어표 썸네일까지 `관리자 템플릿`으로 보이면 의미가 흐려지므로 보관 이미지 자산은 별도 출처와 배지로 분리하는 편이 맞다고 판단했다. - 평소 운영자가 가장 먼저 봐야 하는 대상도 1회성 썸네일이 아니라 실제 아이템이므로, 아이템 관리 기본 필터는 `전체 이미지`가 아니라 `아이템만 (템플릿+사용자)`로 두고 썸네일/프로필 이미지는 필요할 때만 따로 보게 하는 쪽으로 정리했다. diff --git a/docs/spec.md b/docs/spec.md index 2506696..7d3fe93 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -198,7 +198,8 @@ - `POST /api/admin/tierlists/:tierListId/promote-items` - `POST /api/admin/tierlists/:tierListId/create-template` - `GET /api/admin/custom-items` - - `filter=library`를 기본값으로 사용해 반복 사용 가능한 `사용자 업로드 + 관리자 템플릿 아이템`만 먼저 보여주고, `filter=asset` / `filter=unused-admin`으로는 썸네일·프로필 이미지 같은 보관 자산만 따로 조회한다. + - `filter=library`를 기본값으로 사용해 반복 사용 가능한 `템플릿 아이템 + 사용자 아이템`만 먼저 보여주고, `filter=thumbnail` / `filter=avatar`로는 썸네일 이미지와 프로필 이미지를 따로 조회한다. + - `filter=all|library|template|user|thumbnail|avatar|unused-user`를 사용하며, `filter=asset|unused-admin`은 과거 UI 호환용으로만 유지한다. - `POST /api/admin/custom-items/:itemId/promote` - `DELETE /api/admin/custom-items/:itemId` - `DELETE /api/admin/custom-items` @@ -218,11 +219,11 @@ - 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다. - 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다. - 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다. -- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다. +- 사용자 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다. - 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다. -- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다. -- 아이템 관리 기본 필터는 `아이템만 (템플릿+사용자)`이며, 템플릿 기본 아이템과 사용자 업로드 아이템을 먼저 보여주고 프로필 아바타/티어표 썸네일/템플릿 썸네일 같은 보관 이미지 자산은 별도 필터로 분리한다. -- `/uploads/assets/avatars/`는 `프로필 아바타`, `/uploads/assets/tierlists/`는 `티어표 썸네일`, `/uploads/assets/topics/`는 `템플릿 썸네일`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 실제 템플릿 기본 아이템만 `관리자 템플릿` 배지를 사용한다. +- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다. +- 아이템 관리 기본 필터는 `아이템(템플릿 + 사용자)`이며, 우측 필터 순서는 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`을 사용한다. +- `/uploads/assets/avatars/`는 `프로필 아바타`, `/uploads/assets/tierlists/`와 `/uploads/assets/topics/`는 `썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다. - 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다. - `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다. - `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다. diff --git a/docs/todo.md b/docs/todo.md index eba759e..599a9d2 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,10 +1,12 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.57`에서 관리자 아이템 필터 순서를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`으로 바꿨으므로, 우측 셀렉트 순서와 실제 필터링 결과가 같은 의미로 동작하는지 QA한다. +- `썸네일 이미지` 필터에서는 `/uploads/assets/tierlists`, `/uploads/assets/topics`만 모이고, `프로필 이미지` 필터에서는 `/uploads/assets/avatars`만 모이며, 각 카드 배지가 `썸네일 이미지 / 프로필 아바타`로 구분되는지 확인한다. +- `미사용 아이템` 필터는 사용자 아이템 중 저장 티어표 사용 횟수와 템플릿 연결이 모두 0인 항목만 보여주고, 계정 탈퇴로 이미 `custom_items` 레코드가 삭제된 항목이 따로 남지 않는지 확인한다. - `v1.4.56`에서 아이템 관리 기본 필터를 `아이템만 (템플릿+사용자)`로 바꿨으므로, 관리자 화면 첫 진입 시 프로필 아바타/티어표 썸네일 같은 1회성 자산이 기본 목록에서 빠지고 실제 템플릿/사용자 아이템만 보이는지 확인한다. -- `썸네일·프로필 이미지` 필터를 선택했을 때는 `/uploads/assets/avatars`, `/uploads/assets/tierlists`, `/uploads/assets/topics` 경로에 따라 각각 `프로필 아바타`, `티어표 썸네일`, `템플릿 썸네일` 배지가 붙고, 일반 템플릿 기본 아이템만 `관리자 템플릿`으로 남는지 QA한다. -- 보관 이미지 자산도 이름 변경, 템플릿에 추가, 개별 삭제가 기존처럼 동작하고, 미사용 썸네일·프로필 이미지 필터에서는 해당 자산만 따로 모아 보이는지 확인한다. -- 아이템 관리 상단 통계의 `미사용 사용자 아이템` 수치는 프로필/썸네일 자산을 포함하지 않고, 실제 사용자 업로드 아이템 중 사용 횟수와 템플릿 연결이 모두 0인 항목만 세는지 확인한다. +- 보관 이미지 자산도 이름 변경, 템플릿에 추가, 개별 삭제가 기존처럼 동작하는지 확인한다. +- 아이템 관리 상단 통계의 `미사용 아이템` 수치는 프로필/썸네일 자산을 포함하지 않고, 실제 사용자 아이템 중 사용 횟수와 템플릿 연결이 모두 0인 항목만 세는지 확인한다. - `v1.4.55`에서 회원 카드의 `최근 활동`을 `최근 콘텐츠 활동`으로 바꾸고 `마지막 접속일`을 따로 추가했으므로, 티어표를 수정하지 않고 로그인만 한 계정은 마지막 접속일만 갱신되고 최근 콘텐츠 활동은 유지되는지, 반대로 로그인 없이 과거 티어표만 있던 계정은 두 값이 다르게 보이는지 QA한다. - `/api/auth/me`에서도 `last_login_at`을 10분 단위 이상 간격으로만 갱신하도록 넣었으므로, 새로고침을 반복해도 과도한 DB 쓰기가 생기지 않으면서 실제 재접속 후에는 마지막 접속일이 자연스럽게 갱신되는지 확인한다. - 관리자 회원 목록의 `마지막 접속순` 정렬과 회원 카드의 `프로필 보기` 버튼이 정상 동작하고, 버튼 클릭 시 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동하는지 확인한다. diff --git a/docs/update.md b/docs/update.md index e96a48d..70bbbcc 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 2026-04-03 v1.4.57 +- 관리자 아이템 관리 필터를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템` 순서로 다시 정리해, 전체 조회와 실제 아이템 검수 흐름이 더 직관적으로 이어지게 맞췄다. +- 기본 필터는 계속 `아이템(템플릿 + 사용자)`를 유지하되, 썸네일과 프로필 이미지는 각각 `filter=thumbnail`, `filter=avatar`로 분리 조회할 수 있게 백엔드 필터 enum과 자산 분류 값을 확장했다. +- 보관 자산 배지 문구도 `/uploads/assets/topics/` 경로는 `썸네일 이미지`, 사용자 업로드 항목은 `사용자 아이템`, 템플릿 기본 항목은 `템플릿 아이템`으로 맞춰 `관리자 템플릿`처럼 실제 의미와 어긋나는 표현이 남지 않도록 정리했다. +- `미사용 아이템`은 계정 탈퇴로 같이 삭제된 항목이 아니라, 사용자 아이템 레코드는 남아 있지만 저장 티어표/템플릿에서 더 이상 참조하지 않는 항목이라는 의미가 드러나도록 통계 라벨과 일괄 삭제 버튼 문구를 다시 정돈했다. + ## 2026-04-03 v1.4.56 - 관리자 아이템 관리에서 `/uploads/assets/...` 아래의 보관 이미지가 템플릿 기본 아이템이 아닌데도 모두 `관리자 템플릿` 배지로 표시되던 분류를 정리했다. - 보관 이미지 자산은 이제 `asset` 출처로 분리하고, 경로에 따라 `프로필 아바타`, `티어표 썸네일`, `템플릿 썸네일`, `보관 자산` 배지가 붙도록 바꿔 반복 사용 아이템과 1회성/관리용 이미지를 구분해서 볼 수 있게 했다. diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index b12f435..20cd86c 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -231,7 +231,7 @@ const activeTabDescription = computed(() => { return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.' } if (activeTab.value === 'items') { - return '사용자 업로드와 관리자 템플릿 이미지를 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.' + return '사용자 아이템과 템플릿 아이템을 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.' } if (activeTab.value === 'tierlists') { return tierlistsMode.value === 'requests' @@ -269,7 +269,7 @@ const adminOverviewStats = computed(() => { if (activeTab.value === 'items') { return [ { label: '검색 결과', value: `${customItemTotal.value}` }, - { label: '미사용 사용자 아이템', value: `${orphanItems}` }, + { label: '미사용 아이템', value: `${orphanItems}` }, { label: '템플릿 아이템', value: `${customItems.value.filter((item) => item.sourceType === 'template').length}` }, { label: '이미지 자산', value: `${customItems.value.filter((item) => item.sourceType === 'asset').length}` }, ] @@ -612,7 +612,7 @@ function customItemDeleteImpactText(item) { return `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.` } - return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.` + return `"${item.label}" 사용자 아이템을 삭제할까요? 현재 항목만 정리됩니다.` } const imageDiagnosticsCards = computed(() => { @@ -2296,18 +2296,18 @@ function openUserProfile(user) {
- +