feat(member): 썸네일 교체/삭제 시 자산 자동 정리 추가
회원 아바타를 교체·삭제·탈퇴하는 흐름에서 이전 썸네일 파일과 메타데이터가 남지 않도록 공통 정리 로직을 연결했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-11 v0.0.73
|
||||
|
||||
### 회원 썸네일 생명주기 정리
|
||||
|
||||
회원 아바타는 교체가 빈번해 파일이 누적되기 쉬우므로, 업로드 성공 후 이전 회원 전용 썸네일 자산을 자동 정리하도록 했다. 또한 회원이 직접 썸네일을 제거하거나 탈퇴할 때도 동일한 정리 로직을 재사용해 고아 파일과 불필요한 `media_metadata` 레코드가 남지 않게 했다.
|
||||
|
||||
## 2026-05-11 v0.0.72
|
||||
|
||||
### 회원 썸네일 미디어 분리
|
||||
|
||||
@@ -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 | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||
|
||||
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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`으로 분리.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.72",
|
||||
"version": "0.0.73",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
34
server/api/auth/avatar.delete.js
Normal file
34
server/api/auth/avatar.delete.js
Normal 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 }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
66
server/utils/member-avatar.js
Normal file
66
server/utils/member-avatar.js
Normal 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}
|
||||
`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user