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">
|
<div class="site-header__user-menu relative">
|
||||||
<button
|
<button
|
||||||
ref="userMenuToggleRef"
|
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"
|
type="button"
|
||||||
aria-label="Toggle user menu"
|
aria-label="Toggle user menu"
|
||||||
:aria-expanded="menuUserOpen.toString()"
|
:aria-expanded="menuUserOpen.toString()"
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-11 v0.0.73
|
||||||
|
|
||||||
|
### 회원 썸네일 생명주기 정리
|
||||||
|
|
||||||
|
회원 아바타는 교체가 빈번해 파일이 누적되기 쉬우므로, 업로드 성공 후 이전 회원 전용 썸네일 자산을 자동 정리하도록 했다. 또한 회원이 직접 썸네일을 제거하거나 탈퇴할 때도 동일한 정리 로직을 재사용해 고아 파일과 불필요한 `media_metadata` 레코드가 남지 않게 했다.
|
||||||
|
|
||||||
## 2026-05-11 v0.0.72
|
## 2026-05-11 v0.0.72
|
||||||
|
|
||||||
### 회원 썸네일 미디어 분리
|
### 회원 썸네일 미디어 분리
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
|
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
|
||||||
| server/api/auth/profile.put.js | 회원 프로필 수정 API |
|
| server/api/auth/profile.put.js | 회원 프로필 수정 API |
|
||||||
| server/api/auth/avatar.post.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/check-username.get.js | 닉네임 중복 확인 API |
|
||||||
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
|
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
|
||||||
| server/api/auth/account.delete.js | 회원 탈퇴 API |
|
| server/api/auth/account.delete.js | 회원 탈퇴 API |
|
||||||
@@ -162,6 +163,7 @@
|
|||||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||||
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
||||||
|
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·파일/메타데이터 정리 유틸리티 |
|
||||||
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||||
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||||
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||||
|
|||||||
@@ -340,11 +340,13 @@ components/content/
|
|||||||
- `GET /api/auth/profile` - 회원 설정 조회
|
- `GET /api/auth/profile` - 회원 설정 조회
|
||||||
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, 썸네일)
|
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, 썸네일)
|
||||||
- `POST /api/auth/avatar` - 회원 썸네일 이미지 업로드
|
- `POST /api/auth/avatar` - 회원 썸네일 이미지 업로드
|
||||||
|
- `DELETE /api/auth/avatar` - 회원 썸네일 제거
|
||||||
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
|
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
|
||||||
- `PUT /api/auth/password` - 회원 비밀번호 변경
|
- `PUT /api/auth/password` - 회원 비밀번호 변경
|
||||||
- `DELETE /api/auth/account` - 회원 탈퇴
|
- `DELETE /api/auth/account` - 회원 탈퇴
|
||||||
|
|
||||||
> 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 관리자 미디어 목록에서는 제외한다.
|
> 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 관리자 미디어 목록에서는 제외한다.
|
||||||
|
> 회원 썸네일을 새로 업로드하거나 제거/탈퇴할 때 기존 회원 썸네일 파일과 메타데이터는 자동 정리한다.
|
||||||
|
|
||||||
### 관리자 API (`/admin/api/`)
|
### 관리자 API (`/admin/api/`)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.73
|
||||||
|
|
||||||
|
- 회원 썸네일 교체 시 기존 회원 전용 썸네일 파일/메타데이터를 자동 정리하는 공통 유틸을 추가.
|
||||||
|
- `DELETE /api/auth/avatar`를 추가해 회원 설정에서 썸네일 제거를 지원하고, 제거 시 프로필 `avatarUrl`을 비움.
|
||||||
|
- 회원 탈퇴 시에도 회원 전용 썸네일 파일/메타데이터가 함께 정리되도록 처리.
|
||||||
|
|
||||||
## v0.0.72
|
## v0.0.72
|
||||||
|
|
||||||
- 회원 썸네일 업로드 API(`POST /api/auth/avatar`)를 추가하고 업로드 경로를 `/uploads/members/avatars/YYYY/MM`으로 분리.
|
- 회원 썸네일 업로드 API(`POST /api/auth/avatar`)를 추가하고 업로드 경로를 `/uploads/members/avatars/YYYY/MM`으로 분리.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.72",
|
"version": "0.0.73",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const savingProfile = ref(false)
|
|||||||
const savingPassword = ref(false)
|
const savingPassword = ref(false)
|
||||||
const deletingAccount = ref(false)
|
const deletingAccount = ref(false)
|
||||||
const uploadingAvatar = ref(false)
|
const uploadingAvatar = ref(false)
|
||||||
|
const removingAvatar = ref(false)
|
||||||
const profileMessage = ref('')
|
const profileMessage = ref('')
|
||||||
const passwordMessage = ref('')
|
const passwordMessage = ref('')
|
||||||
const deleteMessage = 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 - 파일 선택 이벤트
|
* @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)]"
|
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://..."
|
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>
|
<label class="text-xs site-muted">썸네일 업로드</label>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createError, readBody } from 'h3'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { deleteMember, getUserByIdWithPassword } from '../../repositories/member-repository'
|
import { deleteMember, getUserByIdWithPassword } from '../../repositories/member-repository'
|
||||||
import { clearMemberSession, requireMemberSession } from '../../utils/member-auth'
|
import { clearMemberSession, requireMemberSession } from '../../utils/member-auth'
|
||||||
|
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||||
|
|
||||||
const deleteAccountSchema = z.object({
|
const deleteAccountSchema = z.object({
|
||||||
password: z.string().min(1)
|
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)
|
await deleteMember(session.userId)
|
||||||
clearMemberSession(event)
|
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 { getPostgresClient } from '../../repositories/postgres-client'
|
||||||
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
|
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
|
||||||
import { requireMemberSession } from '../../utils/member-auth'
|
import { requireMemberSession } from '../../utils/member-auth'
|
||||||
|
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||||
|
|
||||||
const allowedImageTypes = new Map([
|
const allowedImageTypes = new Map([
|
||||||
['image/jpeg', '.jpg'],
|
['image/jpeg', '.jpg'],
|
||||||
@@ -114,6 +115,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentUser.avatarUrl && currentUser.avatarUrl !== avatarUrl) {
|
||||||
|
await removeManagedAvatarAsset(currentUser.avatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
avatarUrl
|
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