diff --git a/docs/history.md b/docs/history.md index f337abe..449021e 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-12 v0.0.91 + +### 썸네일 미사용 자산과 업로드 파일명 + +프로필에서 바뀐 옛 썸네일을 바로 디스크에서 지우면 관리자가 누가 올린 자산인지 추적하기 어렵다. 메타만 끊고 파일은 남겨 썸네일 탭에서 정리하도록 바꿨다. 삭제·이름 변경 차단은 `avatar_url`이 가리키는 경우로 한정했다. 게시물 업로드는 UUID 접미 대신 원본명과 넘버링으로 검색 가능성을 높였다. + ## 2026-05-12 v0.0.90 ### 관리자 미디어 라이브러리와 썸네일 탭 분리 diff --git a/docs/map.md b/docs/map.md index 8d44ec7..98f8c3f 100644 --- a/docs/map.md +++ b/docs/map.md @@ -81,7 +81,7 @@ | pages/admin/pages/index.vue | 페이지 목록 | | pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 | | pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 | -| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 상단 탭, 라이브러리: 폴더 트리·폴더 추가 모달·폴더 삭제·드래그 이동·썸네일 탭: 회원 아바타만·검색(닉네임 등), 썸네일 클릭 미리보기 모달(연결 회원 블록), 좌상단 선택 토글·Shift 범위 | +| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집) | | pages/admin/navigation/index.vue | 메뉴/네비게이션 관리(변경 시에만 메뉴 저장 활성) | | pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 | | pages/admin/tags/new.vue | 태그 생성 | @@ -150,7 +150,7 @@ | server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API | | server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API | | server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API | -| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API(성공 시 `media_metadata`를 `미분류`로 기록) | +| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API(원본명 기반 파일명·충돌 시 넘버링, 성공 시 `media_metadata`를 `미분류`로 기록) | | server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) | | server/routes/admin/api/tags.post.js | 관리자 태그 생성 API | | server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API | @@ -167,7 +167,7 @@ | server/utils/sample-content.js | 샘플 콘텐츠 저장소 | | server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 | | server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 | -| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·파일/메타데이터 정리 유틸리티 | +| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) | | server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 | | server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 | | server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 | diff --git a/docs/spec.md b/docs/spec.md index eb2e6e9..207d0bf 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -365,7 +365,7 @@ components/content/ > 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 업로드 시 WebP로 변환하고 `AVATAR_MIN_WIDTH`/`AVATAR_MIN_HEIGHT` 최소 해상도 검증 후 중앙 기준 1:1 정사각형으로 크롭한다. > 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다. > `AVATAR_WEBP_QUALITY`는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다. -> 회원 썸네일을 새로 업로드하거나 제거/탈퇴할 때 기존 회원 썸네일 파일과 메타데이터는 자동 정리한다. +> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다. > 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다. ### 관리자 API (`/admin/api/`) @@ -384,12 +384,12 @@ components/content/ - `PUT /admin/api/pages/:id` - 고정 페이지 수정 - `DELETE /admin/api/pages/:id` - 고정 페이지 삭제 - `GET /admin/api/media` - 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 `avatarOwner` 요약이 붙을 수 있음) -- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더 `썸네일`만 허용, 일반 미디어를 `썸네일`로 옮기는 것은 거부) -- `DELETE /admin/api/media` - 업로드 미디어 삭제(회원 아바타 디스크 경로 파일은 거부) +- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더 `썸네일`만 허용, 일반 미디어를 `썸네일`로 옮기는 것은 거부; 썸네일 파일명 변경은 `users.avatar_url`이 해당 URL을 참조할 때만 거부) +- `DELETE /admin/api/media` - 업로드 미디어 삭제(게시물·페이지에서 사용 중이면 거부; `/members/avatars/` URL은 `users.avatar_url`이 해당 URL을 참조할 때만 거부) - `GET /admin/api/media-folders` - 미디어 폴더 목록 - `POST /admin/api/media-folders` - 미디어 폴더 생성 - `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림) -- `POST /admin/api/uploads` - 관리자 이미지 업로드 +- `POST /admin/api/uploads` - 관리자 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링) - `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`) - `POST /admin/api/tags` - 태그 생성 - `GET /admin/api/tags/:id` - 태그 상세 @@ -559,9 +559,9 @@ components/content/ /uploads/system/favicon.png ``` -- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). +- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다. - `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category`가 `미분류`로 저장된 항목이 여기에 모인다. -- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다. +- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 저장 파일명은 원본명 기반(동일 폴더 충돌 시 `-2` 넘버링)이다. 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다. - 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다. - 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다. @@ -572,12 +572,13 @@ components/content/ - 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다. - 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다. - 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다. +- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다). - 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다. - 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다. - 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다. - 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다. - 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다. -- 회원 프로필 썸네일 파일은 관리자 화면에서 파일명 변경·삭제를 차단한다. +- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다. --- diff --git a/docs/update.md b/docs/update.md index cf7d050..77691ca 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,13 @@ # 업데이트 이력 +## v0.0.91 + +- 회원 썸네일 교체·삭제·탈퇴 시 이전 파일은 디스크에 남기고 `media_metadata`만 제거해, 관리자 썸네일 탭에서 미사용 자산을 구분·삭제할 수 있게 함. +- 관리자 미디어: 프로필이 참조 중인 썸네일만 삭제·이름 변경 차단(미참조 파일은 허용). +- `POST /admin/api/uploads`·`POST /api/auth/avatar`: 저장 파일명은 원본명 기반, 동일 폴더 충돌 시 `-2` 넘버링. +- 관리자 미디어 검색: 파일명·게시물 사용처 제목만; 모달에서 폴더 요약 중복 행 제거. +- `renameMediaItem`: 대상 폴더에 동일 파일명이 있으면 409. + ## v0.0.90 - 관리자 미디어: 상단 탭으로 **미디어 라이브러리**와 **썸네일**(회원 `/members/avatars/`만) 분리, 썸네일 검색에 닉네임·이메일·IP 반영. diff --git a/package.json b/package.json index 440b2a2..4383793 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.90", + "version": "0.0.91", "private": true, "type": "module", "imports": { diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index 6b9b2d1..a23095d 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -133,15 +133,9 @@ const filteredMediaItems = computed(() => { ? true : (!folder || item.category === folder || item.category?.startsWith(`${folder}/`)) const usageTitles = item.usage?.map((usage) => usage.title) || [] - const ownerFields = item.avatarOwner - ? [item.avatarOwner.username, item.avatarOwner.email, item.avatarOwner.lastSeenIp] - : [] const matchesQuery = !query || [ item.name, - item.url, - item.category, - ...usageTitles, - ...ownerFields + ...usageTitles ].some((value) => String(value || '').toLowerCase().includes(query)) return matchesFolder && matchesQuery @@ -467,7 +461,7 @@ const renameMedia = async () => { const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value) if (editingItem && isMediaItemLocked(editingItem)) { - errorMessage.value = '사용 중이거나 회원 썸네일인 미디어는 파일명을 변경할 수 없습니다.' + errorMessage.value = '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 파일명을 바꿀 수 없습니다.' return } @@ -496,7 +490,7 @@ const renameMedia = async () => { */ const deleteMedia = async (item) => { if (isMediaItemLocked(item)) { - errorMessage.value = '사용 중이거나 회원 썸네일인 미디어는 삭제할 수 없습니다.' + errorMessage.value = '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 삭제할 수 없습니다.' return } @@ -558,7 +552,7 @@ const deleteMedia = async (item) => { v-model="searchText" class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72" type="search" - :placeholder="activeTab === 'thumbnails' ? '닉네임, 이메일, IP, 파일명 검색' : '파일명, 경로, 폴더, 사용처 검색'" + :placeholder="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'" > @@ -641,7 +635,7 @@ const deleteMedia = async (item) => { {{ thumbnailMediaItems.length }}

- 회원 프로필에서 저장된 이미지만 표시됩니다. 파일 삭제·이름 변경은 이 화면에서 할 수 없으며, 회원이 프로필에서 바꾸면 갱신됩니다. + 회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제된 파일은 이 목록에 남으며, 관리자가 직접 정리할 수 있습니다.

@@ -804,10 +798,6 @@ const deleteMedia = async (item) => {
용량
{{ formatFileSize(selectedMedia.size) }}
-
-
폴더
-
{{ selectedMedia.category }}
-
@@ -903,7 +893,7 @@ const deleteMedia = async (item) => { @keydown.enter.prevent="renameMedia" >

- 사용 중이거나 회원 썸네일인 미디어는 파일명 변경과 삭제가 잠겨 있습니다. + 게시물·페이지에서 사용 중이거나, 회원 프로필에 연결된 썸네일은 파일명 변경과 삭제가 잠깁니다.

diff --git a/server/api/auth/avatar.post.js b/server/api/auth/avatar.post.js index e303f17..0087779 100644 --- a/server/api/auth/avatar.post.js +++ b/server/api/auth/avatar.post.js @@ -1,5 +1,4 @@ -import { randomUUID } from 'node:crypto' -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { createError, readMultipartFormData } from 'h3' import sharp from 'sharp' @@ -48,6 +47,36 @@ const clampNumber = (value, minimum, maximum) => { return Math.round(value) } +/** + * 아바타 저장 디렉터리에서 비어 있는 `.webp` 파일명을 고른다. 동일 stem이 있으면 `-2`, `-3` 넘버링한다. + * @param {string} directoryPath - 저장 디렉터리 절대 경로 + * @param {string} stem - 확장자 제외 파일명 + * @returns {Promise<{ fileName: string, filePath: string }>} 파일명과 절대 경로 + */ +const pickUniqueWebpFileName = async (directoryPath, stem) => { + let suffix = 1 + + while (suffix < 10000) { + const fileName = suffix === 1 ? `${stem}.webp` : `${stem}-${suffix}.webp` + const filePath = join(directoryPath, fileName) + + try { + await stat(filePath) + suffix += 1 + } catch { + return { + fileName, + filePath + } + } + } + + throw createError({ + statusCode: 500, + message: '저장할 고유 파일명을 만들 수 없습니다.' + }) +} + /** * 회원 썸네일 업로드 API * @param {import('h3').H3Event} event - 요청 이벤트 @@ -104,9 +133,8 @@ export default defineEventHandler(async (event) => { await mkdir(directoryPath, { recursive: true }) - const originalName = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'avatar' - const fileName = `${originalName}-${randomUUID()}.webp` - const filePath = join(directoryPath, fileName) + const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'avatar' + const { fileName, filePath } = await pickUniqueWebpFileName(directoryPath, originalStem) const avatarUrl = `${uploadBaseUrl}/members/avatars/${year}/${month}/${fileName}` const metadata = await sharp(file.data).metadata() diff --git a/server/routes/admin/api/uploads.post.js b/server/routes/admin/api/uploads.post.js index 4cf3fba..ba57f1f 100644 --- a/server/routes/admin/api/uploads.post.js +++ b/server/routes/admin/api/uploads.post.js @@ -1,5 +1,4 @@ -import { randomUUID } from 'node:crypto' -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, stat, writeFile } from 'node:fs/promises' import { extname, join } from 'node:path' import { createError, readMultipartFormData } from 'h3' import { requireAdminSession } from '../../../utils/admin-auth' @@ -37,6 +36,37 @@ const getUploadExtension = (file) => { return extension } +/** + * 디렉터리 안에서 비어 있는 저장 파일명을 고른다. 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다. + * @param {string} directoryPath - 저장 디렉터리 절대 경로 + * @param {string} stem - 확장자 제외 파일명 + * @param {string} extension - 확장자(점 포함, 예: `.png`) + * @returns {Promise<{ fileName: string, filePath: string }>} 선택된 파일명과 절대 경로 + */ +const pickUniqueDiskFileName = async (directoryPath, stem, extension) => { + let suffix = 1 + + while (suffix < 10000) { + const fileName = suffix === 1 ? `${stem}${extension}` : `${stem}-${suffix}${extension}` + const filePath = join(directoryPath, fileName) + + try { + await stat(filePath) + suffix += 1 + } catch { + return { + fileName, + filePath + } + } + } + + throw createError({ + statusCode: 500, + message: '저장할 고유 파일명을 만들 수 없습니다.' + }) +} + /** * 관리자 이미지 업로드 API * @param {import('h3').H3Event} event - 요청 이벤트 @@ -83,10 +113,9 @@ export default defineEventHandler(async (event) => { }) } - const originalName = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'image' + const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'image' const extension = getUploadExtension(file) - const fileName = `${originalName}-${randomUUID()}${extension}` - const filePath = join(directoryPath, fileName) + const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, originalStem, extension) await writeFile(filePath, file.data) @@ -96,7 +125,7 @@ export default defineEventHandler(async (event) => { uploadedFiles.push({ url: publicUrl, - name: file.filename, + name: fileName, size: file.data.length }) } diff --git a/server/utils/media-library.js b/server/utils/media-library.js index 6ffcf63..efae453 100644 --- a/server/utils/media-library.js +++ b/server/utils/media-library.js @@ -96,17 +96,28 @@ const assertCategoryMoveAllowed = (urls, normalizedCategory) => { } /** - * 회원 아바타 파일은 라이브러리에서 직접 삭제·이름 변경하지 않는다. + * 해당 URL이 회원 프로필 `avatar_url`로 참조 중인지 확인한다. * @param {string} url - 미디어 URL - * @returns {void} + * @returns {Promise} 참조 중이면 true */ -const assertNotMemberAvatarFile = (url) => { - if (isMemberAvatarPublicUrl(url)) { - throw createError({ - statusCode: 400, - message: '회원 프로필 썸네일은 이 화면에서 삭제·이름 변경할 수 없습니다.' - }) +const isAvatarUrlReferencedByProfile = async (url) => { + if (!isMemberAvatarPublicUrl(url)) { + return false } + + const sql = getPostgresClient() + if (!sql) { + return false + } + + const rows = await sql` + SELECT 1 + FROM users + WHERE avatar_url = ${url} + LIMIT 1 + ` + + return rows.length > 0 } /** @@ -604,8 +615,6 @@ export const updateMediaCategories = async (urls, category) => { * @returns {Promise} */ export const deleteMediaItem = async (url) => { - assertNotMemberAvatarFile(url) - const [posts, pages] = await Promise.all([ listAdminPosts(), listPages() @@ -619,6 +628,13 @@ export const deleteMediaItem = async (url) => { }) } + if (await isAvatarUrlReferencedByProfile(url)) { + throw createError({ + statusCode: 409, + message: '회원 프로필에서 사용 중인 썸네일은 삭제할 수 없습니다.' + }) + } + await rm(resolveMediaPath(url)) await deleteMediaMetadata(url) } @@ -630,8 +646,6 @@ export const deleteMediaItem = async (url) => { * @returns {Promise} 변경된 미디어 항목 */ export const renameMediaItem = async (url, name) => { - assertNotMemberAvatarFile(url) - const [posts, pages] = await Promise.all([ listAdminPosts(), listPages() @@ -645,6 +659,13 @@ export const renameMediaItem = async (url, name) => { }) } + if (await isAvatarUrlReferencedByProfile(url)) { + throw createError({ + statusCode: 409, + message: '회원 프로필에서 사용 중인 썸네일은 파일명을 변경할 수 없습니다.' + }) + } + const currentPath = resolveMediaPath(url) const currentExtension = extname(currentPath) const cleanName = sanitizeMediaName(name.replace(/\.[^.]+$/g, '')) @@ -658,6 +679,22 @@ export const renameMediaItem = async (url, name) => { const nextPath = join(dirname(currentPath), `${cleanName}${currentExtension}`) + if (currentPath === nextPath) { + return createMediaItem(currentPath) + } + + try { + await stat(nextPath) + throw createError({ + statusCode: 409, + message: '같은 폴더에 동일한 파일명이 이미 있습니다.' + }) + } catch (err) { + if (err.statusCode === 409) { + throw err + } + } + await rename(currentPath, nextPath) const renamedItem = await createMediaItem(nextPath) diff --git a/server/utils/member-avatar.js b/server/utils/member-avatar.js index 3135ea2..9bc93fe 100644 --- a/server/utils/member-avatar.js +++ b/server/utils/member-avatar.js @@ -1,4 +1,3 @@ -import { rm } from 'node:fs/promises' import { join, relative } from 'node:path' import { createError } from 'h3' import { getPostgresClient } from '../repositories/postgres-client' @@ -41,7 +40,7 @@ export const resolveMemberAvatarPath = (url) => { } /** - * 회원 썸네일 파일과 메타데이터를 정리한다. + * 프로필에서 썸네일 URL을 끊을 때 `media_metadata` 행만 제거한다. 디스크 파일은 유지해 관리자 미디어(썸네일 탭)에서 미사용 자산으로 확인·삭제할 수 있게 한다. * @param {string} url - 정리 대상 URL * @returns {Promise} */ @@ -50,9 +49,6 @@ export const removeManagedAvatarAsset = async (url) => { return } - const filePath = resolveMemberAvatarPath(url) - await rm(filePath, { force: true }) - const sql = getPostgresClient() if (!sql) { return