썸네일 미참조 삭제 허용·원본명 업로드·미디어 검색 정리(v0.0.91)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 10:52:57 +09:00
parent 21024602b0
commit 16bb9370fa
10 changed files with 150 additions and 55 deletions

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-12 v0.0.91
### 썸네일 미사용 자산과 업로드 파일명
프로필에서 바뀐 옛 썸네일을 바로 디스크에서 지우면 관리자가 누가 올린 자산인지 추적하기 어렵다. 메타만 끊고 파일은 남겨 썸네일 탭에서 정리하도록 바꿨다. 삭제·이름 변경 차단은 `avatar_url`이 가리키는 경우로 한정했다. 게시물 업로드는 UUID 접미 대신 원본명과 넘버링으로 검색 가능성을 높였다.
## 2026-05-12 v0.0.90
### 관리자 미디어 라이브러리와 썸네일 탭 분리

View File

@@ -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 | 관리자 사이트 설정 입력값 검증 스키마 |

View File

@@ -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을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.
---

View File

@@ -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 반영.

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.90",
"version": "0.0.91",
"private": true,
"type": "module",
"imports": {

View File

@@ -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' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'"
>
</div>
</div>
@@ -641,7 +635,7 @@ const deleteMedia = async (item) => {
<span>{{ thumbnailMediaItems.length }}</span>
</button>
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
회원 프로필에서 저장된 이미지 표시됩니다. 파일 삭제·이름 변경 화면에서 으며, 회원이 프로필에서 바꾸면 갱신됩니다.
회원 프로필에서 쓰는 이미지 연결 회원이 있을 때만 삭제·이름 변경 막힙니다. 프로필에서 바꾸거나 해제된 파일은 목록에 으며, 관리자가 직접 정리할 있습니다.
</p>
</aside>
@@ -804,10 +798,6 @@ const deleteMedia = async (item) => {
<dt class="admin-media__info-label text-xs font-semibold text-muted">용량</dt>
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
</div>
<div class="admin-media__info-row">
<dt class="admin-media__info-label text-xs font-semibold text-muted">폴더</dt>
<dd class="admin-media__info-value mt-1 break-all">{{ selectedMedia.category }}</dd>
</div>
</dl>
<div class="admin-media__category grid gap-2">
@@ -903,7 +893,7 @@ const deleteMedia = async (item) => {
@keydown.enter.prevent="renameMedia"
>
<p v-if="isMediaItemLocked(selectedMedia)" class="admin-media__locked text-xs text-muted">
사용 중이거나 회원 썸네일인 미디어는 파일명 변경과 삭제가 있습니다.
게시물·페이지에서 사용 중이거나, 회원 프로필에 연결된 썸네일은 파일명 변경과 삭제가 니다.
</p>
</div>

View File

@@ -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()

View File

@@ -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
})
}

View File

@@ -96,17 +96,28 @@ const assertCategoryMoveAllowed = (urls, normalizedCategory) => {
}
/**
* 회원 아바타 파일은 라이브러리에서 직접 삭제·이름 변경하지 않는다.
* 해당 URL이 회원 프로필 `avatar_url`로 참조 중인지 확인한다.
* @param {string} url - 미디어 URL
* @returns {void}
* @returns {Promise<boolean>} 참조 중이면 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<void>}
*/
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<Object>} 변경된 미디어 항목
*/
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)

View File

@@ -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<void>}
*/
@@ -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