From dddb57333cc29e678bdd6bcbdd5c4be1d11f81ac Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 3 Apr 2026 13:53:48 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=9E=90?= =?UTF-8?q?=EC=82=B0=20=EB=B6=84=EB=A5=98=EC=99=80=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B6=84=EC=82=B0=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 36 ++++++++++++++++++++++++++++---- backend/src/lib/image-storage.js | 9 +++++--- docs/history.md | 4 ++++ docs/spec.md | 5 +++-- docs/todo.md | 3 +++ docs/update.md | 5 +++++ 6 files changed, 53 insertions(+), 9 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index df1f1b4..f27d2bc 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1829,6 +1829,32 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod getCustomItemUsageMeta(), ]) + const [userAvatarRows, topicThumbnailRows, tierListThumbnailRows, templateRequestThumbnailRows] = await Promise.all([ + query("SELECT avatar_src AS src FROM users WHERE avatar_src <> ''"), + query("SELECT thumbnail_src AS src FROM topics WHERE thumbnail_src <> ''"), + query("SELECT thumbnail_src AS src FROM tierlists WHERE thumbnail_src <> ''"), + query("SELECT thumbnail_src_snapshot AS src FROM template_requests WHERE thumbnail_src_snapshot <> ''"), + ]) + + const avatarSrcSet = new Set(userAvatarRows.map((row) => row.src).filter(Boolean)) + const thumbnailSrcSet = new Set([ + ...topicThumbnailRows.map((row) => row.src).filter(Boolean), + ...tierListThumbnailRows.map((row) => row.src).filter(Boolean), + ...templateRequestThumbnailRows.map((row) => row.src).filter(Boolean), + ]) + + const resolveLibraryAssetKind = (src) => { + if (avatarSrcSet.has(src)) return 'avatar' + if (thumbnailSrcSet.has(src)) return 'thumbnail' + return getAssetLibraryKind(src) + } + + const resolveLibraryAssetLabel = (src) => { + if (avatarSrcSet.has(src)) return '프로필 아바타' + if (thumbnailSrcSet.has(src)) return '썸네일 이미지' + return getAssetLibrarySourceLabel(src) + } + const templateLinkedBySrc = new Map() topicItemRows.forEach((row) => { if (!row?.src) return @@ -1851,6 +1877,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod ownerEmail: row.email, usageCount: usageMeta.usageMap.get(row.id) || 0, linkedTemplates, + assetKind: resolveLibraryAssetKind(row.src), sourceType: 'user', sourceLabel: '사용자 아이템', canDelete: true, @@ -1873,8 +1900,8 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod usageCount: 0, linkedTemplates: [], sourceType: 'asset', - sourceLabel: getAssetLibrarySourceLabel(row.src), - assetKind: getAssetLibraryKind(row.src), + sourceLabel: resolveLibraryAssetLabel(row.src), + assetKind: resolveLibraryAssetKind(row.src), canDelete: true, sourceTopicId: '', sourceTopicName: '', @@ -1891,6 +1918,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod ownerEmail: '', usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size, linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()), + assetKind: resolveLibraryAssetKind(row.src), sourceType: 'template', sourceLabel: '템플릿 아이템', canDelete: true, @@ -1958,9 +1986,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod case 'asset': return item.sourceType === 'asset' || !!item.isAssetLibraryItem case 'thumbnail': - return item.sourceType === 'asset' && item.assetKind === 'thumbnail' + return item.assetKind === 'thumbnail' case 'avatar': - return item.sourceType === 'asset' && item.assetKind === 'avatar' + return item.assetKind === 'avatar' case 'library': return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem) case 'unused-user': diff --git a/backend/src/lib/image-storage.js b/backend/src/lib/image-storage.js index 8dc50e3..5f100ec 100644 --- a/backend/src/lib/image-storage.js +++ b/backend/src/lib/image-storage.js @@ -75,10 +75,13 @@ async function optimizeAndPersist({ file, width, height, fit, quality }) { } } - const filename = nanoid() + '.webp' - const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR) + const basename = nanoid() + const shardDirectory = basename.slice(0, 2) + const filename = basename + '.webp' + const relativeDir = path.join(OPTIMIZED_DIR, shardDirectory) + const absoluteDir = path.join(UPLOAD_ROOT, relativeDir) const absolutePath = path.join(absoluteDir, filename) - const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename + const src = '/uploads/' + relativeDir.split(path.sep).join('/') + '/' + filename await fs.mkdir(absoluteDir, { recursive: true }) await fs.writeFile(absolutePath, data) diff --git a/docs/history.md b/docs/history.md index 90231e6..caef87f 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-03 v1.4.59 +- 최근 최적화 이미지가 `assets` 바로 아래 평면 파일로 저장되면서 경로만으로 프로필/썸네일 역할을 구분할 수 없게 되었으므로, 관리자 아이템 분류는 폴더명 규칙 하나에만 기대지 말고 실제 DB 참조 컬럼을 역추적해 판별하는 편이 더 안전하다고 판단했다. +- 이미지가 장기적으로 많이 쌓일 수 있는 서비스라면 한 폴더에 모든 파일을 계속 몰아넣기보다 적당한 수준의 하위 폴더 분산이 낫다고 보고, 신규 파일만 ID 앞 2글자로 1단계 샤딩 저장하되 기존 평면 경로는 그대로 유지하는 점진 방식으로 정리했다. + ## 2026-04-03 v1.4.58 - 작성자 프로필 화면 상단에서 닉네임과 `@accountName`을 다시 보여주면 바로 아래 프로필 카드의 동일 정보와 역할이 겹치므로, 상단은 페이지 성격을 설명하는 공통 제목으로 두고 실제 사용자 식별 정보는 프로필 카드 한 곳에만 모으는 편이 낫다고 판단했다. - `@accountName`은 사용자가 직접 만든 핸들이 아니라 이메일 앞부분 기반 표시라서 계정명이 따로 존재하는 것처럼 오해를 만들 수 있으므로, 별도 사용자명 정책을 도입하기 전까지는 공개 프로필 UI에서 숨기는 쪽으로 정리했다. diff --git a/docs/spec.md b/docs/spec.md index 147f2f1..c2fa80c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -24,6 +24,7 @@ - 아바타: `backend/uploads/avatars/` - 커스텀 아이템: `backend/uploads/custom/` - 시드 이미지: `backend/uploads/seeds/` + - 최적화 이미지 자산: 신규 업로드는 `backend/uploads/assets/<앞2글자>/<파일명>.webp` 형태로 1단계 샤딩 저장하고, 기존 `backend/uploads/assets/<파일명>.webp` 평면 경로도 계속 읽는다. ## 화면 구조 - 좌측 패널 @@ -198,7 +199,7 @@ - `POST /api/admin/tierlists/:tierListId/promote-items` - `POST /api/admin/tierlists/:tierListId/create-template` - `GET /api/admin/custom-items` - - `filter=library`를 기본값으로 사용해 반복 사용 가능한 `템플릿 아이템 + 사용자 아이템`만 먼저 보여주고, `filter=thumbnail` / `filter=avatar`로는 썸네일 이미지와 프로필 이미지를 따로 조회한다. + - `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` @@ -223,7 +224,7 @@ - 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다. - 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다. - 아이템 관리 기본 필터는 `아이템(템플릿 + 사용자)`이며, 우측 필터 순서는 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`을 사용한다. -- `/uploads/assets/avatars/`는 `프로필 아바타`, `/uploads/assets/tierlists/`와 `/uploads/assets/topics/`는 `썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다. +- `/uploads/assets/avatars/`는 `프로필 아바타`, `/uploads/assets/tierlists/`와 `/uploads/assets/topics/`는 `썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 최근처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로만 보고 종류를 알 수 없는 자산은 DB 참조(`avatar_src`, `thumbnail_src`, `thumbnail_src_snapshot`)를 역추적해 프로필/썸네일 여부를 분류한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다. - 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다. - `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다. - `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다. diff --git a/docs/todo.md b/docs/todo.md index 1a78e9a..9fce140 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,9 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.59`에서 `thumbnail/avatar` 필터를 실제 DB 참조 역할 기준으로 다시 판별하도록 바꿨으므로, 최근 업로드처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로여도 썸네일 이미지/프로필 이미지 필터에서 빠지지 않는지 확인한다. +- 신규 업로드 이미지는 `/uploads/assets/<앞2글자>/<파일명>.webp`로 저장되므로, 템플릿 썸네일/티어표 썸네일/프로필 아바타/아이템 업로드를 각각 새로 올린 뒤 실제 파일이 샤딩 폴더에 생성되고, 브라우저 표시·삭제·중복 재사용이 모두 기존처럼 동작하는지 QA한다. +- 기존 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지되므로, 예전에 만든 티어표 썸네일과 아이템 이미지가 새 저장 구조 변경 후에도 깨지지 않는지 확인한다. - `v1.4.58`에서 작성자 프로필 상단 헤더를 `사용자 프로필` 공통 제목으로 바꾸고 `@accountName` 노출을 뺐으므로, `/users/:userId`에서 상단 문구와 본문 프로필 카드가 중복되지 않고 닉네임/아바타/팔로우 버튼만 자연스럽게 읽히는지 확인한다. - `v1.4.57`에서 관리자 아이템 필터 순서를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`으로 바꿨으므로, 우측 셀렉트 순서와 실제 필터링 결과가 같은 의미로 동작하는지 QA한다. - `썸네일 이미지` 필터에서는 `/uploads/assets/tierlists`, `/uploads/assets/topics`만 모이고, `프로필 이미지` 필터에서는 `/uploads/assets/avatars`만 모이며, 각 카드 배지가 `썸네일 이미지 / 프로필 아바타`로 구분되는지 확인한다. diff --git a/docs/update.md b/docs/update.md index afb486a..0ee80e9 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-03 v1.4.59 +- 최근 업로드된 최적화 이미지가 `/uploads/assets/<파일명>.webp`처럼 하위 폴더 없이 저장되면서, `썸네일 이미지 / 프로필 이미지` 필터가 경로 문자열만으로 자산 종류를 판별하지 못해 비어 보일 수 있던 문제를 고쳤다. +- 관리자 아이템 목록 생성 시 `users.avatar_src`, `topics.thumbnail_src`, `tierlists.thumbnail_src`, `template_requests.thumbnail_src_snapshot`을 역으로 모아 해당 `src`가 프로필 이미지인지 썸네일 이미지인지 먼저 판별하고, `thumbnail/avatar` 필터는 `sourceType`이 아니라 이 실제 참조 역할(`assetKind`) 기준으로 걸리도록 보정했다. +- 신규 최적화 이미지 저장은 한 폴더에 무한정 쌓이지 않도록 파일 ID 앞 2글자 기준으로 `/uploads/assets/ab/<파일명>.webp`처럼 1단계 샤딩 디렉터리를 사용하게 바꿨다. 기존에 이미 저장된 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지해 과거 이미지 링크가 깨지지 않게 했다. + ## 2026-04-03 v1.4.58 - 작성자 프로필 화면 상단 헤더가 `Author + 닉네임 + @accountName`을 다시 보여주면서, 바로 아래 프로필 카드의 아바타/닉네임 정보와 거의 같은 내용이 반복되던 구성을 정리했다. - 상단 헤더는 공통 제목 `사용자 프로필`과 안내 문구로 바꾸고, 실제 닉네임은 아래 프로필 카드에서만 보여주도록 나눠 화면의 정보 역할이 겹치지 않게 했다.