관리자 미디어 라이브러리·썸네일 탭 분리 및 논리 폴더 정책(v0.0.90)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 10:40:27 +09:00
parent 05176609ee
commit 21024602b0
10 changed files with 488 additions and 95 deletions

View File

@@ -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()