미디어 본문 이미지 사용처 계산 수정

This commit is contained in:
2026-07-03 15:27:39 +09:00
parent 288e73c0dc
commit 33c92a6f72
10 changed files with 112 additions and 14 deletions

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.10.22에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.10.27에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-07-03 v1.10.27 — 미디어 사용처 계산은 목록 요약과 분리한다
관리자 글 목록은 성능을 위해 본문을 제외한 요약 응답을 사용한다. 하지만 미디어 사용처 계산은 게시물 본문과 페이지 본문 안의 이미지 URL을 정확히 확인해야 하므로 같은 목록 함수를 재사용하면 본문 이미지가 모두 미사용으로 분류될 수 있다. 따라서 미디어 관리는 목록 UI용 함수가 아니라 미디어 참조 필드만 따로 조회하는 전용 함수를 사용한다.
자동 생성 파생 이미지는 원본 이미지에서 다시 만들 수 있는 시스템 파일이지만, 사용 중이면 원본과 마찬가지로 삭제되면 안 된다. 사용처가 없는 고아 파생 파일만 정리용으로 삭제할 수 있게 두고, 파일명 변경은 원본 연결이 깨질 수 있어 계속 제한한다.
## 2026-07-03 v1.10.26 — 관리자 고정은 공개 추천과 분리한다
비공개 글을 작성 대기 목록처럼 상단에 유지하고 싶은 요구는 공개 홈의 추천 글 노출과 목적이 다르다. 추천(`isFeatured`)을 재사용하면 공개 화면 노출 의도와 관리자 작업 큐가 섞이므로, 게시물에는 관리자 전용 `adminPinned` 플래그를 별도로 둔다. 글 목록은 정렬 방식과 무관하게 고정 글을 먼저 보여 주고, 토글은 목록 행 메뉴에서만 제공해 글쓰기 자동 저장 payload와 충돌하지 않게 한다.

View File

@@ -99,6 +99,7 @@
| server/routes/rss.get.js | 공개 RSS 2.0 피드 별칭(`/rss`) |
| server/plugins/site-custom-code.js | 공개 Nuxt HTML 응답에 사이트 설정 헤더·푸터 코드 삽입(`/admin`, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 제외) |
| server/utils/post-thumbnail-image.js | 게시물 업로드 이미지의 카드 썸네일·Pages 3:4 커버 썸네일·본문 표시용 1600px/720px WebP·라이트박스용 2560px WebP 이미지 URL·디스크 경로·생성 가능 여부·본문 이미지 추출 규칙 |
| server/utils/media-library.js | 관리자 미디어 파일 스캔·폴더 메타·사용처 계산·삭제/이름 변경, 미디어 참조 전용 게시물·페이지 조회 기반 원본/파생 이미지 사용처 연결 |
| server/utils/sitemap.js | 공개 sitemap XML·robots.txt 생성 |
| server/utils/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 |

View File

@@ -111,7 +111,7 @@
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `srcset`으로 화면 폭에 맞는 파일을 우선 사용한다.
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외된다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.
- 카드·본문·라이트박스·Pages 커버 파생 이미지는 임시 파일에 먼저 생성하고 Sharp 메타데이터로 읽기 검증한 뒤 최종 파일로 교체한다. 생성 또는 검증에 실패하면 깨진 파생 파일이 남지 않도록 대상 파일을 제거하고 공개 화면은 원본 fallback을 사용한다. 파생 이미지는 같은 URL로 재생성될 수 있으므로 `/uploads/posts/YYYY/MM/thumbs/*.webp` 응답은 `no-cache`로 재검증하고, 공개 본문과 관리자 미디어 미리보기에는 파생 이미지 캐시 버전 쿼리를 붙여 기존 브라우저 캐시를 우회한다.
- 공개 게시물 응답은 원본 대표 이미지 `featuredImage`를 유지하고, 대응 썸네일 파일이 실제로 존재할 때만 `featuredImageThumbnail`을 추가한다.
- 기존 대표 이미지는 로컬에서 `npm run images:backfill-post-thumbnails`, NAS 운영에서 `sh scripts/backfill-post-thumbnails-production.sh`로 게시물 대표 이미지 URL만 스캔해 누락된 카드 썸네일을 생성한다. 관리자 미디어의 원본 이미지 상세에서도 카드 썸네일을 개별 생성·재생성할 수 있다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.10.27
- 관리자 미디어 사용처 계산이 본문 없는 글 목록 요약 대신 미디어 참조 전용 게시물·페이지 조회를 사용하도록 수정.
- 본문 이미지 탭의 자동 생성 파생 이미지가 실제 본문 사용 중이어도 미사용으로 집계되던 문제 수정.
- 관리자 미디어 상세에서 자동 생성 이미지 파일명 변경 제한과 고아 파생 파일 삭제 용도를 안내하도록 정리.
## v1.10.26
- 관리자 글 목록 전용 상단 고정 플래그(`adminPinned`) 추가.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.10.26",
"version": "1.10.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.10.26",
"version": "1.10.27",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

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

View File

@@ -2010,8 +2010,8 @@ watch(() => mediaPagination.value.page, (page) => {
<p v-if="isMediaItemLocked(selectedMedia)" class="admin-media__locked text-xs text-muted">
게시물·페이지에서 사용 중이거나, 회원 프로필에 연결된 썸네일은 파일명 변경과 삭제가 잠깁니다.
</p>
<p v-else-if="isPostCardThumbnailDiskItem(selectedMedia)" class="admin-media__locked text-xs text-muted">
카드 썸네일 파일명은 원본 이미지와의 연결을 유지하기 위해 변경할 수 없습니다.
<p v-else-if="isPostCardThumbnailDiskItem(selectedMedia) || isPageCoverThumbnailDiskItem(selectedMedia) || isPostBodyThumbnailDiskItem(selectedMedia)" class="admin-media__locked text-xs text-muted">
자동 생성 이미지는 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다. 사용처가 없는 파생 파일만 정리용으로 삭제할 수 있습니다.
</p>
</div>

View File

@@ -776,6 +776,46 @@ export const listAdminPosts = async (options = {}) => {
}
}
/**
* 미디어 사용처 계산용 게시물 참조 목록 조회
* @returns {Promise<Array<{ id: string, title: string, slug: string, status: string, featuredImage: string | null, content: string }>>} 게시물 미디어 참조 목록
*/
export const listMediaReferencePosts = async () => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePosts().map((post) => ({
id: post.id,
title: post.title,
slug: post.slug,
status: post.status,
featuredImage: post.featuredImage,
content: post.content || ''
}))
}
const rows = await sql`
SELECT
id,
title,
slug,
status,
featured_image,
content
FROM posts
ORDER BY updated_at DESC
`
return rows.map((row) => ({
id: row.id,
title: toAdminPostFormTitle(row.title),
slug: row.slug,
status: row.status,
featuredImage: row.featured_image,
content: row.content || ''
}))
}
/**
* 관리자 게시물 상세 조회
* @param {string} id - 게시물 ID
@@ -1145,6 +1185,46 @@ export const listPages = async (options = {}) => {
return rows.map(mapPageRow)
}
/**
* 미디어 사용처 계산용 페이지 참조 목록 조회
* @returns {Promise<Array<{ id: string, title: string, slug: string, status: string, featuredImage: string | null, content: string }>>} 페이지 미디어 참조 목록
*/
export const listMediaReferencePages = async () => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePages().map((page) => ({
id: page.id,
title: page.title,
slug: page.slug,
status: page.status,
featuredImage: page.featuredImage,
content: page.content || ''
}))
}
const rows = await sql`
SELECT
id,
title,
slug,
status,
featured_image,
content
FROM pages
ORDER BY updated_at DESC
`
return rows.map((row) => ({
id: row.id,
title: row.title,
slug: row.slug,
status: row.status,
featuredImage: row.featured_image,
content: row.content || ''
}))
}
/**
* 관리자 고정 페이지 목록 조회
* @returns {Promise<Array>} 관리자 고정 페이지 목록

View File

@@ -1,7 +1,12 @@
import { readdir, rename, rm, stat } from 'node:fs/promises'
import { basename, dirname, extname, join, relative } from 'node:path'
import { createError } from 'h3'
import { getSiteSettings, listAdminPosts, listAdminTags, listPages } from '../repositories/content-repository'
import {
getSiteSettings,
listAdminTags,
listMediaReferencePages,
listMediaReferencePosts
} from '../repositories/content-repository'
import { getPostgresClient } from '../repositories/postgres-client'
import {
getPageCoverThumbnailUrl,
@@ -956,8 +961,8 @@ export const listMediaItems = async () => {
const items = await readMediaDirectory(uploadRoot)
const metadataMap = await getMediaMetadataMap()
const [posts, pages, siteSettings, tags] = await Promise.all([
listAdminPosts(),
listPages(),
listMediaReferencePosts(),
listMediaReferencePages(),
getSiteSettings(),
listAdminTags()
])
@@ -1126,8 +1131,8 @@ export const updateMediaCategories = async (urls, category) => {
*/
export const deleteMediaItem = async (url) => {
const [posts, pages, siteSettings, tags] = await Promise.all([
listAdminPosts(),
listPages(),
listMediaReferencePosts(),
listMediaReferencePages(),
getSiteSettings(),
listAdminTags()
])
@@ -1187,8 +1192,8 @@ export const renameMediaItem = async (url, name) => {
}
const [posts, pages, siteSettings, tags] = await Promise.all([
listAdminPosts(),
listPages(),
listMediaReferencePosts(),
listMediaReferencePages(),
getSiteSettings(),
listAdminTags()
])