feat(member): 회원 썸네일 업로드를 작가 미디어와 분리

회원 아바타 자산을 전용 경로로 분리해 작가용 미디어 목록과 섞이지 않게 하고, 설정 화면에서 파일 업로드로 바로 반영할 수 있게 했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 17:16:21 +09:00
parent f5cd73b223
commit 6e8ca97779
8 changed files with 185 additions and 3 deletions

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-11 v0.0.72
### 회원 썸네일 미디어 분리
회원 썸네일은 운영자(작가) 콘텐츠 제작용 미디어와 목적이 다르므로, 업로드 경로를 `/uploads/members/avatars/YYYY/MM`로 분리했다. 관리자 미디어 목록에서는 해당 경로를 숨겨, 작가용 미디어 라이브러리와 회원 프로필 자산이 섞이지 않도록 했다.
## 2026-05-11 v0.0.71
### 회원 UX를 헤더 중심으로 전환

View File

@@ -123,6 +123,7 @@
| server/api/auth/logout.post.js | 회원 로그아웃 API |
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
| server/api/auth/profile.put.js | 회원 프로필 수정 API |
| server/api/auth/avatar.post.js | 회원 썸네일 업로드 API |
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
| server/api/auth/account.delete.js | 회원 탈퇴 API |
@@ -168,7 +169,7 @@
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
| server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 |
| server/utils/navigation-items.js | 네비게이션 기본값과 그룹 유틸리티 |
| server/utils/media-library.js | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티 |
| server/utils/media-library.js | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티(회원 썸네일 경로 제외) |
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |

View File

@@ -339,10 +339,13 @@ components/content/
- `POST /api/auth/logout` - 회원 로그아웃
- `GET /api/auth/profile` - 회원 설정 조회
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, 썸네일)
- `POST /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/`)
- `POST /admin/api/auth/login` - 로그인

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v0.0.72
- 회원 썸네일 업로드 API(`POST /api/auth/avatar`)를 추가하고 업로드 경로를 `/uploads/members/avatars/YYYY/MM`으로 분리.
- 회원 설정 페이지에서 썸네일 파일 업로드를 직접 처리하고, 업로드 후 프로필 저장 흐름으로 연결.
- 관리자 미디어 목록에서 회원 썸네일 경로(`/uploads/members/avatars/`)를 제외해 작가용 미디어와 분리.
## v0.0.71
- 헤더 사용자 영역에서 구독 버튼을 제거하고, 로그인 상태 기반 아바타/드롭다운(설정, 로그아웃 / 비로그인 시 Sign up, Sign in)으로 정리.

View File

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

View File

@@ -3,6 +3,7 @@ const loading = ref(true)
const savingProfile = ref(false)
const savingPassword = ref(false)
const deletingAccount = ref(false)
const uploadingAvatar = ref(false)
const profileMessage = ref('')
const passwordMessage = ref('')
const deleteMessage = ref('')
@@ -98,6 +99,41 @@ const saveProfile = async () => {
}
}
/**
* 썸네일 파일을 업로드한다.
* @param {Event} event - 파일 선택 이벤트
* @returns {Promise<void>}
*/
const uploadAvatar = async (event) => {
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
const file = target?.files?.[0]
if (!file || uploadingAvatar.value) {
return
}
uploadingAvatar.value = true
profileMessage.value = ''
try {
const formData = new FormData()
formData.append('file', file)
const result = await $fetch('/api/auth/avatar', {
method: 'POST',
body: formData
})
profileForm.avatarUrl = result.avatarUrl || ''
profileMessage.value = '썸네일이 업로드되었습니다. 프로필 저장을 눌러 반영하세요.'
} catch (error) {
profileMessage.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
} finally {
uploadingAvatar.value = false
if (target) {
target.value = ''
}
}
}
/**
* 비밀번호를 변경한다.
* @returns {Promise<void>}
@@ -195,6 +231,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://..."
>
<label class="text-xs site-muted">썸네일 업로드</label>
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="block w-full text-xs site-muted file:mr-3 file:cursor-pointer file:rounded-[8px] file:border file:border-[var(--site-line)] file:bg-[var(--site-panel)] file:px-3 file:py-1.5"
:disabled="uploadingAvatar"
@change="uploadAvatar"
>
<button
type="button"
class="site-accent-button mt-1 w-fit rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"

View File

@@ -0,0 +1,121 @@
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import { getPostgresClient } from '../../repositories/postgres-client'
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
const allowedImageTypes = new Map([
['image/jpeg', '.jpg'],
['image/png', '.png'],
['image/webp', '.webp'],
['image/gif', '.gif']
])
/**
* 업로드 경로 조각을 URL 안전 문자열로 정리
* @param {string} value - 원본 경로 조각
* @returns {string} 정리된 경로 조각
*/
const sanitizePathPart = (value) => value
.replace(/[^a-zA-Z0-9가-힣._-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
/**
* 파일 확장자 조회
* @param {Object} file - multipart 파일 파트
* @returns {string} 확장자
*/
const getUploadExtension = (file) => {
const extension = extname(file.filename || '').toLowerCase()
if (allowedImageTypes.has(file.type)) {
return allowedImageTypes.get(file.type)
}
return extension
}
/**
* 회원 썸네일 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ avatarUrl: string }>} 업로드 결과
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const currentUser = await getUserById(session.userId)
if (!currentUser) {
throw createError({
statusCode: 404,
message: '회원 정보를 찾을 수 없습니다.'
})
}
const config = useRuntimeConfig()
const maxFileSize = Number(config.maxFileSize || 10485760)
const formData = await readMultipartFormData(event)
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
if (!file) {
throw createError({
statusCode: 400,
message: '업로드할 이미지가 없습니다.'
})
}
if (!allowedImageTypes.has(file.type)) {
throw createError({
statusCode: 400,
message: '이미지 파일만 업로드할 수 있습니다.'
})
}
if (file.data.length > maxFileSize) {
throw createError({
statusCode: 413,
message: '업로드 가능한 파일 크기를 초과했습니다.'
})
}
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'members', 'avatars', year, month)
await mkdir(directoryPath, { recursive: true })
const originalName = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'avatar'
const extension = getUploadExtension(file)
const fileName = `${originalName}-${randomUUID()}${extension}`
const filePath = join(directoryPath, fileName)
const avatarUrl = `${uploadBaseUrl}/members/avatars/${year}/${month}/${fileName}`
await writeFile(filePath, file.data)
await updateMemberProfile({
userId: session.userId,
username: currentUser.username,
avatarUrl
})
const sql = getPostgresClient()
if (sql) {
await sql`
INSERT INTO media_metadata (url, category)
VALUES (${avatarUrl}, ${'회원/썸네일'})
ON CONFLICT (url) DO UPDATE
SET
category = EXCLUDED.category,
updated_at = now()
`
}
return {
avatarUrl
}
})

View File

@@ -252,12 +252,13 @@ const getMediaUsage = (url, posts, pages) => {
*/
export const listMediaItems = async () => {
const items = await readMediaDirectory(uploadRoot)
const publicAdminItems = items.filter((item) => !item.url.startsWith('/uploads/members/avatars/'))
const metadataMap = await getMediaMetadataMap()
const [posts, pages] = await Promise.all([
listAdminPosts(),
listPages()
])
const itemsWithUsage = items.map((item) => ({
const itemsWithUsage = publicAdminItems.map((item) => ({
...item,
category: metadataMap[item.url]?.category || item.category,
usage: getMediaUsage(item.url, posts, pages)