관리자 미디어 라이브러리·썸네일 탭 분리 및 논리 폴더 정책(v0.0.90)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,12 +6,182 @@ import { getPostgresClient } from '../repositories/postgres-client'
|
||||
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
|
||||
/** 회원 프로필 이미지 전용 논리 폴더명(디스크 경로와 별도) */
|
||||
export const MEDIA_THUMBNAIL_ROOT = '썸네일'
|
||||
|
||||
/**
|
||||
* 회원 아바타 공개 URL 여부
|
||||
* @param {string} url - 미디어 URL
|
||||
* @returns {boolean} 아바타 경로이면 true
|
||||
*/
|
||||
export const isMemberAvatarPublicUrl = (url) => typeof url === 'string' && url.includes('/members/avatars/')
|
||||
|
||||
/**
|
||||
* 미디어 카테고리 정리
|
||||
* @param {string} category - 입력 카테고리
|
||||
* @returns {string} 정리된 카테고리
|
||||
*/
|
||||
const normalizeMediaCategory = (category) => String(category || '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/|\/$/g, '')
|
||||
|| '미분류'
|
||||
|
||||
/**
|
||||
* 기본 미디어 카테고리 이름 반환
|
||||
* @param {string} relativePath - 업로드 루트 기준 상대 경로
|
||||
* @returns {string} 기본 카테고리
|
||||
*/
|
||||
const getDefaultMediaCategory = (relativePath) => relativePath.split('/')[0] || '미분류'
|
||||
const getDefaultMediaCategory = (relativePath) => {
|
||||
if (relativePath.startsWith('posts/')) {
|
||||
return '미분류'
|
||||
}
|
||||
|
||||
return relativePath.split('/')[0] || '미분류'
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 논리 폴더명을 화면·API 기준으로 정규화한다.
|
||||
* @param {string} url - 미디어 URL
|
||||
* @param {string} category - DB 또는 디스크 기본 카테고리
|
||||
* @returns {string} 정규화된 카테고리
|
||||
*/
|
||||
const normalizeStoredDisplayCategory = (url, category) => {
|
||||
if (isMemberAvatarPublicUrl(url)) {
|
||||
return MEDIA_THUMBNAIL_ROOT
|
||||
}
|
||||
|
||||
const base = normalizeMediaCategory(category)
|
||||
|
||||
if (base === 'posts' || base === '회원/썸네일') {
|
||||
return '미분류'
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* 논리 폴더 경로 목록에서 썸네일 전용 루트를 제외한다.
|
||||
* @param {Array<string>} paths - 폴더 경로 목록
|
||||
* @returns {Array<string>} 필터된 목록
|
||||
*/
|
||||
const excludeThumbnailFolderPaths = (paths) => paths.filter((pathValue) => pathValue !== MEDIA_THUMBNAIL_ROOT
|
||||
&& !pathValue.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`))
|
||||
|
||||
/**
|
||||
* 썸네일 전용 폴더 경로에 대한 변경을 검증한다.
|
||||
* @param {Array<string>} urls - 대상 URL 목록
|
||||
* @param {string} normalizedCategory - 목표 논리 폴더
|
||||
* @returns {void}
|
||||
*/
|
||||
const assertCategoryMoveAllowed = (urls, normalizedCategory) => {
|
||||
const uniqueUrls = [...new Set(urls.filter(Boolean))]
|
||||
const hasAvatar = uniqueUrls.some((u) => isMemberAvatarPublicUrl(u))
|
||||
const hasNonAvatar = uniqueUrls.some((u) => !isMemberAvatarPublicUrl(u))
|
||||
|
||||
if (hasNonAvatar && normalizedCategory === MEDIA_THUMBNAIL_ROOT) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '일반 미디어를 썸네일 폴더로 옮길 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (hasAvatar && normalizedCategory !== MEDIA_THUMBNAIL_ROOT) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '프로필 썸네일의 논리 폴더는 「썸네일」로만 유지됩니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 아바타 파일은 라이브러리에서 직접 삭제·이름 변경하지 않는다.
|
||||
* @param {string} url - 미디어 URL
|
||||
* @returns {void}
|
||||
*/
|
||||
const assertNotMemberAvatarFile = (url) => {
|
||||
if (isMemberAvatarPublicUrl(url)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원 프로필 썸네일은 이 화면에서 삭제·이름 변경할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 목록에 대해 아바타를 사용 중인 회원 정보를 조회한다.
|
||||
* @param {Array<string>} urls - 미디어 URL 목록
|
||||
* @returns {Promise<Map<string, { id: string, username: string, email: string, lastSeenAt: string | null, lastSeenIp: string }>>} URL별 회원 요약
|
||||
*/
|
||||
const getAvatarOwnersByUrls = async (urls) => {
|
||||
const sql = getPostgresClient()
|
||||
const uniqueUrls = [...new Set(urls.filter((u) => isMemberAvatarPublicUrl(u)))]
|
||||
|
||||
if (!sql || !uniqueUrls.length) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
last_seen_at,
|
||||
last_seen_ip,
|
||||
avatar_url
|
||||
FROM users
|
||||
WHERE avatar_url IN ${sql(uniqueUrls)}
|
||||
`
|
||||
const ownerByUrl = new Map()
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.avatar_url || ownerByUrl.has(row.avatar_url)) {
|
||||
continue
|
||||
}
|
||||
|
||||
ownerByUrl.set(row.avatar_url, {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
lastSeenAt: row.last_seen_at ? row.last_seen_at.toISOString() : null,
|
||||
lastSeenIp: row.last_seen_ip || ''
|
||||
})
|
||||
}
|
||||
|
||||
return ownerByUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 논리 폴더 메타를 upsert한다.
|
||||
* @param {string} url - 미디어 URL
|
||||
* @param {string} category - 논리 폴더명
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const upsertMediaMetadataCategory = async (url, category) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedCategory = normalizeMediaCategory(category)
|
||||
|
||||
await sql`
|
||||
INSERT INTO media_metadata (
|
||||
url,
|
||||
category
|
||||
)
|
||||
VALUES (
|
||||
${url},
|
||||
${normalizedCategory}
|
||||
)
|
||||
ON CONFLICT (url) DO UPDATE
|
||||
SET
|
||||
category = EXCLUDED.category,
|
||||
updated_at = now()
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 메타데이터 목록을 URL 기준 객체로 조회
|
||||
@@ -35,18 +205,6 @@ const getMediaMetadataMap = async () => {
|
||||
}]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 카테고리 정리
|
||||
* @param {string} category - 입력 카테고리
|
||||
* @returns {string} 정리된 카테고리
|
||||
*/
|
||||
const normalizeMediaCategory = (category) => String(category || '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/|\/$/g, '')
|
||||
|| '미분류'
|
||||
|
||||
/**
|
||||
* 미디어 폴더 목록 조회
|
||||
* @returns {Promise<Array<string>>} 미디어 폴더 경로 목록
|
||||
@@ -55,10 +213,15 @@ export const listMediaFolders = async () => {
|
||||
const sql = getPostgresClient()
|
||||
const items = await readMediaDirectory(uploadRoot)
|
||||
const metadataMap = await getMediaMetadataMap()
|
||||
const defaultCategories = items.map((item) => metadataMap[item.url]?.category || item.category)
|
||||
const defaultCategories = items.map((item) => {
|
||||
const rawCategory = metadataMap[item.url]?.category || item.category
|
||||
|
||||
return normalizeStoredDisplayCategory(item.url, rawCategory)
|
||||
})
|
||||
|
||||
if (!sql) {
|
||||
return [...new Set(['미분류', ...defaultCategories])].sort((left, right) => left.localeCompare(right))
|
||||
return excludeThumbnailFolderPaths([...new Set(['미분류', ...defaultCategories])])
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
@@ -67,11 +230,11 @@ export const listMediaFolders = async () => {
|
||||
ORDER BY path ASC
|
||||
`
|
||||
|
||||
return [...new Set([
|
||||
return excludeThumbnailFolderPaths([...new Set([
|
||||
'미분류',
|
||||
...rows.map((row) => row.path),
|
||||
...defaultCategories
|
||||
])].sort((left, right) => left.localeCompare(right))
|
||||
])]).sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +250,13 @@ export const createMediaFolder = async (path) => {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
if (normalizedPath === MEDIA_THUMBNAIL_ROOT || normalizedPath.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '「썸네일」 폴더는 시스템에서만 관리합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
await sql`
|
||||
INSERT INTO media_folders (path)
|
||||
VALUES (${normalizedPath})
|
||||
@@ -119,6 +289,13 @@ export const deleteMediaFolder = async (path) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (normalizedPath === MEDIA_THUMBNAIL_ROOT || normalizedPath.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '「썸네일」 폴더는 삭제할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const childPrefix = `${normalizedPath}/`
|
||||
|
||||
await sql.begin(async (tx) => {
|
||||
@@ -301,11 +478,19 @@ export const listMediaItems = async () => {
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
])
|
||||
const itemsWithUsage = items.map((item) => ({
|
||||
...item,
|
||||
category: metadataMap[item.url]?.category || item.category,
|
||||
usage: getMediaUsage(item.url, posts, pages)
|
||||
}))
|
||||
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
|
||||
const itemsWithUsage = items.map((item) => {
|
||||
const rawCategory = metadataMap[item.url]?.category ?? item.category
|
||||
const category = normalizeStoredDisplayCategory(item.url, rawCategory)
|
||||
const avatarOwner = avatarOwnerByUrl.get(item.url) || null
|
||||
|
||||
return {
|
||||
...item,
|
||||
category,
|
||||
usage: getMediaUsage(item.url, posts, pages),
|
||||
avatarOwner
|
||||
}
|
||||
})
|
||||
|
||||
return itemsWithUsage.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt))
|
||||
}
|
||||
@@ -376,7 +561,11 @@ export const updateMediaCategories = async (urls, category) => {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
await createMediaFolder(normalizedCategory)
|
||||
assertCategoryMoveAllowed(urls, normalizedCategory)
|
||||
|
||||
if (normalizedCategory !== MEDIA_THUMBNAIL_ROOT) {
|
||||
await createMediaFolder(normalizedCategory)
|
||||
}
|
||||
|
||||
const items = []
|
||||
|
||||
@@ -401,7 +590,7 @@ export const updateMediaCategories = async (urls, category) => {
|
||||
const item = await createMediaItem(mediaPath)
|
||||
items.push({
|
||||
...item,
|
||||
category: normalizedCategory,
|
||||
category: normalizeStoredDisplayCategory(url, normalizedCategory),
|
||||
usage: []
|
||||
})
|
||||
}
|
||||
@@ -415,6 +604,8 @@ export const updateMediaCategories = async (urls, category) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteMediaItem = async (url) => {
|
||||
assertNotMemberAvatarFile(url)
|
||||
|
||||
const [posts, pages] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
@@ -439,6 +630,8 @@ export const deleteMediaItem = async (url) => {
|
||||
* @returns {Promise<Object>} 변경된 미디어 항목
|
||||
*/
|
||||
export const renameMediaItem = async (url, name) => {
|
||||
assertNotMemberAvatarFile(url)
|
||||
|
||||
const [posts, pages] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
|
||||
Reference in New Issue
Block a user