feat(member): 썸네일 교체/삭제 시 자산 자동 정리 추가

회원 아바타를 교체·삭제·탈퇴하는 흐름에서 이전 썸네일 파일과 메타데이터가 남지 않도록 공통 정리 로직을 연결했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 17:20:50 +09:00
parent 6e8ca97779
commit eab800b6c1
11 changed files with 162 additions and 2 deletions

View File

@@ -200,7 +200,7 @@ onBeforeUnmount(() => {
<div class="site-header__user-menu relative">
<button
ref="userMenuToggleRef"
class="site-header__user-toggle relative flex h-7 w-7 items-center justify-center rounded-full transition-opacity duration-200 hover:opacity-75 md:h-8 md:w-8"
class="site-header__user-toggle relative flex h-7 w-7 items-center justify-center rounded-full border transition-opacity duration-200 hover:opacity-75 md:h-8 md:w-8"
type="button"
aria-label="Toggle user menu"
:aria-expanded="menuUserOpen.toString()"

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-11 v0.0.73
### 회원 썸네일 생명주기 정리
회원 아바타는 교체가 빈번해 파일이 누적되기 쉬우므로, 업로드 성공 후 이전 회원 전용 썸네일 자산을 자동 정리하도록 했다. 또한 회원이 직접 썸네일을 제거하거나 탈퇴할 때도 동일한 정리 로직을 재사용해 고아 파일과 불필요한 `media_metadata` 레코드가 남지 않게 했다.
## 2026-05-11 v0.0.72
### 회원 썸네일 미디어 분리

View File

@@ -124,6 +124,7 @@
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
| server/api/auth/profile.put.js | 회원 프로필 수정 API |
| server/api/auth/avatar.post.js | 회원 썸네일 업로드 API |
| server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API |
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
| server/api/auth/account.delete.js | 회원 탈퇴 API |
@@ -162,6 +163,7 @@
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·파일/메타데이터 정리 유틸리티 |
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |

View File

@@ -340,11 +340,13 @@ components/content/
- `GET /api/auth/profile` - 회원 설정 조회
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, 썸네일)
- `POST /api/auth/avatar` - 회원 썸네일 이미지 업로드
- `DELETE /api/auth/avatar` - 회원 썸네일 제거
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
- `PUT /api/auth/password` - 회원 비밀번호 변경
- `DELETE /api/auth/account` - 회원 탈퇴
> 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 관리자 미디어 목록에서는 제외한다.
> 회원 썸네일을 새로 업로드하거나 제거/탈퇴할 때 기존 회원 썸네일 파일과 메타데이터는 자동 정리한다.
### 관리자 API (`/admin/api/`)

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v0.0.73
- 회원 썸네일 교체 시 기존 회원 전용 썸네일 파일/메타데이터를 자동 정리하는 공통 유틸을 추가.
- `DELETE /api/auth/avatar`를 추가해 회원 설정에서 썸네일 제거를 지원하고, 제거 시 프로필 `avatarUrl`을 비움.
- 회원 탈퇴 시에도 회원 전용 썸네일 파일/메타데이터가 함께 정리되도록 처리.
## v0.0.72
- 회원 썸네일 업로드 API(`POST /api/auth/avatar`)를 추가하고 업로드 경로를 `/uploads/members/avatars/YYYY/MM`으로 분리.

View File

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

View File

@@ -4,6 +4,7 @@ const savingProfile = ref(false)
const savingPassword = ref(false)
const deletingAccount = ref(false)
const uploadingAvatar = ref(false)
const removingAvatar = ref(false)
const profileMessage = ref('')
const passwordMessage = ref('')
const deleteMessage = ref('')
@@ -99,6 +100,31 @@ const saveProfile = async () => {
}
}
/**
* 썸네일을 제거한다.
* @returns {Promise<void>}
*/
const removeAvatar = async () => {
if (removingAvatar.value) {
return
}
removingAvatar.value = true
profileMessage.value = ''
try {
await $fetch('/api/auth/avatar', {
method: 'DELETE'
})
profileForm.avatarUrl = ''
profileMessage.value = '썸네일이 제거되었습니다.'
} catch (error) {
profileMessage.value = error?.data?.message || '썸네일 제거에 실패했습니다.'
} finally {
removingAvatar.value = false
}
}
/**
* 썸네일 파일을 업로드한다.
* @param {Event} event - 파일 선택 이벤트
@@ -231,6 +257,14 @@ onMounted(loadProfile)
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
placeholder="https://..."
>
<button
type="button"
class="w-fit rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs site-muted transition-opacity hover:opacity-80 disabled:opacity-60"
:disabled="removingAvatar"
@click="removeAvatar"
>
{{ removingAvatar ? '제거 중...' : '썸네일 제거' }}
</button>
<label class="text-xs site-muted">썸네일 업로드</label>
<input
type="file"

View File

@@ -3,6 +3,7 @@ import { createError, readBody } from 'h3'
import { z } from 'zod'
import { deleteMember, getUserByIdWithPassword } from '../../repositories/member-repository'
import { clearMemberSession, requireMemberSession } from '../../utils/member-auth'
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
const deleteAccountSchema = z.object({
password: z.string().min(1)
@@ -40,6 +41,10 @@ export default defineEventHandler(async (event) => {
})
}
if (user.avatarUrl) {
await removeManagedAvatarAsset(user.avatarUrl)
}
await deleteMember(session.userId)
clearMemberSession(event)

View File

@@ -0,0 +1,34 @@
import { createError } from 'h3'
import { getUserById, updateMemberProfile } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
/**
* 회원 썸네일 삭제 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ ok: true }>} 처리 결과
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const user = await getUserById(session.userId)
if (!user) {
throw createError({
statusCode: 404,
message: '회원 정보를 찾을 수 없습니다.'
})
}
if (user.avatarUrl) {
await removeManagedAvatarAsset(user.avatarUrl)
}
await updateMemberProfile({
userId: user.id,
username: user.username,
avatarUrl: ''
})
return { ok: true }
})

View File

@@ -5,6 +5,7 @@ import { createError, readMultipartFormData } from 'h3'
import { getPostgresClient } from '../../repositories/postgres-client'
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
const allowedImageTypes = new Map([
['image/jpeg', '.jpg'],
@@ -114,6 +115,10 @@ export default defineEventHandler(async (event) => {
`
}
if (currentUser.avatarUrl && currentUser.avatarUrl !== avatarUrl) {
await removeManagedAvatarAsset(currentUser.avatarUrl)
}
return {
avatarUrl
}

View File

@@ -0,0 +1,66 @@
import { rm } from 'node:fs/promises'
import { join, relative } from 'node:path'
import { createError } from 'h3'
import { getPostgresClient } from '../repositories/postgres-client'
const managedAvatarPrefix = '/uploads/members/avatars/'
/**
* 회원 전용 썸네일 URL인지 확인한다.
* @param {string} url - 대상 URL
* @returns {boolean} 회원 썸네일 여부
*/
export const isManagedAvatarUrl = (url) => String(url || '').startsWith(managedAvatarPrefix)
/**
* 회원 썸네일 URL을 실제 파일 경로로 변환한다.
* @param {string} url - 썸네일 URL
* @returns {string} 파일 시스템 경로
*/
export const resolveMemberAvatarPath = (url) => {
if (!isManagedAvatarUrl(url)) {
throw createError({
statusCode: 400,
message: '회원 썸네일 경로가 아닙니다.'
})
}
const decodedUrl = decodeURIComponent(url)
const filePath = join(process.cwd(), 'public', decodedUrl)
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const relativePath = relative(uploadRoot, filePath)
if (relativePath.startsWith('..') || relativePath === '') {
throw createError({
statusCode: 400,
message: '회원 썸네일 경로가 올바르지 않습니다.'
})
}
return filePath
}
/**
* 회원 썸네일 파일과 메타데이터를 정리한다.
* @param {string} url - 정리 대상 URL
* @returns {Promise<void>}
*/
export const removeManagedAvatarAsset = async (url) => {
if (!isManagedAvatarUrl(url)) {
return
}
const filePath = resolveMemberAvatarPath(url)
await rm(filePath, { force: true })
const sql = getPostgresClient()
if (!sql) {
return
}
await sql`
DELETE FROM media_metadata
WHERE url = ${url}
`
}