feat(member): 회원 썸네일 업로드를 작가 미디어와 분리
회원 아바타 자산을 전용 경로로 분리해 작가용 미디어 목록과 섞이지 않게 하고, 설정 화면에서 파일 업로드로 바로 반영할 수 있게 했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-11 v0.0.72
|
||||
|
||||
### 회원 썸네일 미디어 분리
|
||||
|
||||
회원 썸네일은 운영자(작가) 콘텐츠 제작용 미디어와 목적이 다르므로, 업로드 경로를 `/uploads/members/avatars/YYYY/MM`로 분리했다. 관리자 미디어 목록에서는 해당 경로를 숨겨, 작가용 미디어 라이브러리와 회원 프로필 자산이 섞이지 않도록 했다.
|
||||
|
||||
## 2026-05-11 v0.0.71
|
||||
|
||||
### 회원 UX를 헤더 중심으로 전환
|
||||
|
||||
@@ -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 | 회원 조회/생성 저장소 |
|
||||
|
||||
@@ -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` - 로그인
|
||||
|
||||
@@ -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)으로 정리.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.71",
|
||||
"version": "0.0.72",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
121
server/api/auth/avatar.post.js
Normal file
121
server/api/auth/avatar.post.js
Normal 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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user