사이트 로고 캐시 갱신 보강
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.1.7
|
||||
|
||||
- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임.
|
||||
- 현재 사이트 설정에서 사용 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시하고 삭제·파일명 변경을 차단하도록 보강.
|
||||
|
||||
## v1.1.6
|
||||
|
||||
- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-15 v1.1.7
|
||||
|
||||
### 사이트 로고 파일명을 교체마다 고유하게 저장
|
||||
|
||||
사이트 로고 업로드는 미디어 라이브러리에 `시스템` 폴더 메타로 남지만, 기존 구현은 항상 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 덮어썼다. 운영 브라우저와 파비콘 캐시는 같은 URL의 이미지를 오래 보관할 수 있어 파일이 바뀌어도 이전 이미지처럼 보일 수 있다. 따라서 로고와 파비콘은 업로드마다 고유 파일명으로 저장하고 사이트 설정 URL 자체를 갱신한다. 현재 사이트 설정에서 참조 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시해 실수로 이름을 바꾸거나 삭제하지 못하게 했다.
|
||||
|
||||
## 2026-05-15 v1.1.6
|
||||
|
||||
### 일반 태그도 검색 없이 보이는 관리 화면
|
||||
|
||||
@@ -114,12 +114,12 @@
|
||||
| pages/admin/pages/index.vue | 페이지 목록 |
|
||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·파비콘 생성, 저작권 문구) |
|
||||
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·고유 URL 파비콘 생성, 저작권 문구) |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||
@@ -201,6 +201,7 @@
|
||||
| server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API |
|
||||
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
|
||||
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
|
||||
| server/routes/admin/api/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-*.webp`, `/uploads/system/favicon-*.png` 생성, `시스템` 미디어 메타 저장) |
|
||||
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
|
||||
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
||||
|
||||
@@ -550,7 +550,7 @@ components/content/
|
||||
|
||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 함께 생성한다.
|
||||
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-*.webp`와 `/uploads/system/favicon-*.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
|
||||
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||
@@ -612,13 +612,14 @@ components/content/
|
||||
/uploads/posts/YYYY/MM/filename.webp
|
||||
/uploads/pages/YYYY/MM/filename.webp
|
||||
/uploads/members/avatars/YYYY/MM/filename.webp
|
||||
/uploads/system/logo.png
|
||||
/uploads/system/favicon.png
|
||||
/uploads/system/logo-YYYYMMDDTHHMMSS-random.webp
|
||||
/uploads/system/favicon-YYYYMMDDTHHMMSS-random.png
|
||||
```
|
||||
|
||||
- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
|
||||
- `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category`가 `미분류`로 저장된 항목이 여기에 모인다.
|
||||
- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 저장 파일명은 원본명 기반(동일 폴더 충돌 시 `-2` 넘버링)이다. 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다.
|
||||
- 사이트 로고와 파비콘은 `public/uploads/system/`에 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`시스템`**으로 저장한다. 현재 사이트 설정의 `logo_url` 또는 `favicon_url`이 가리키는 파일은 사용 중인 미디어로 표시하고 파일명 변경·삭제를 차단한다.
|
||||
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
|
||||
|
||||
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
||||
@@ -635,7 +636,7 @@ components/content/
|
||||
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
|
||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.1.7
|
||||
|
||||
- 사이트 로고 업로드가 고정 `/uploads/system/logo.webp` 덮어쓰기 대신 고유 파일명 URL을 저장하도록 수정.
|
||||
- 사이트 파비콘도 로고와 같은 고유 접미사 파일명으로 생성해 브라우저 캐시로 이전 이미지가 남는 문제를 완화.
|
||||
- 미디어 라이브러리 사용 현황에 사이트 설정 로고·파비콘 참조를 포함하고, 현재 사용 중인 시스템 이미지는 파일명 변경·삭제가 잠기도록 수정.
|
||||
- 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적 todo 정리.
|
||||
- 패키지 버전 `1.1.7`로 갱신.
|
||||
|
||||
## v1.1.6
|
||||
|
||||
- 관리자 태그 관리 화면에서 일반 태그를 검색 전에도 전체 목록으로 확인할 수 있도록 수정.
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.19",
|
||||
"version": "1.1.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.19",
|
||||
"version": "1.1.7",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -5920,7 +5920,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"license": "MIT"
|
||||
@@ -6920,7 +6920,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/impound": {
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/impound/-/impound-1.1.5.tgz",
|
||||
"integrity": "sha512-5AUn+QE0UofqNHu5f2Skf6Svukdg4ehOIq8O0EtqIx4jta0CDZYBPqpIHt0zrlUTiFVYlLpeH39DoikXBjPKpA==",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -31,6 +31,14 @@ const clampNumber = (value, minimum, maximum) => {
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 로고 파일명에 사용할 짧은 고유 접미사를 만든다.
|
||||
* @returns {string} 파일명 접미사
|
||||
*/
|
||||
const createSystemAssetSuffix = () => `${new Date().toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}Z$/g, '')}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
/**
|
||||
* 사이트 로고 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
@@ -77,10 +85,13 @@ export default defineEventHandler(async (event) => {
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
|
||||
const logoPath = join(directoryPath, 'logo.webp')
|
||||
const faviconPath = join(directoryPath, 'favicon.png')
|
||||
const logoUrl = `${uploadBaseUrl}/system/logo.webp`
|
||||
const faviconUrl = `${uploadBaseUrl}/system/favicon.png`
|
||||
const assetSuffix = createSystemAssetSuffix()
|
||||
const logoFileName = `logo-${assetSuffix}.webp`
|
||||
const faviconFileName = `favicon-${assetSuffix}.png`
|
||||
const logoPath = join(directoryPath, logoFileName)
|
||||
const faviconPath = join(directoryPath, faviconFileName)
|
||||
const logoUrl = `${uploadBaseUrl}/system/${logoFileName}`
|
||||
const faviconUrl = `${uploadBaseUrl}/system/${faviconFileName}`
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join, relative } from 'node:path'
|
||||
import { createError } from 'h3'
|
||||
import { listAdminPosts, listPages } from '../repositories/content-repository'
|
||||
import { getSiteSettings, listAdminPosts, listPages } from '../repositories/content-repository'
|
||||
import { getPostgresClient } from '../repositories/postgres-client'
|
||||
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
@@ -478,6 +478,46 @@ const getMediaUsage = (url, posts, pages) => {
|
||||
return [...postUsages, ...pageUsages]
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 설정에서 미디어 URL 사용처 조회
|
||||
* @param {string} url - 미디어 URL
|
||||
* @param {Object} siteSettings - 사이트 설정
|
||||
* @returns {Array<Object>} 사용처 목록
|
||||
*/
|
||||
const getSiteSettingsMediaUsage = (url, siteSettings) => {
|
||||
const usages = []
|
||||
|
||||
if (siteSettings.logoUrl === url) {
|
||||
usages.push({
|
||||
type: 'settings',
|
||||
typeLabel: '사이트 설정',
|
||||
id: 'site-logo',
|
||||
title: '사이트 로고',
|
||||
adminUrl: '/admin/settings',
|
||||
publicUrl: '/',
|
||||
status: 'system',
|
||||
location: 'logoUrl',
|
||||
label: '사이트 로고'
|
||||
})
|
||||
}
|
||||
|
||||
if (siteSettings.faviconUrl === url) {
|
||||
usages.push({
|
||||
type: 'settings',
|
||||
typeLabel: '사이트 설정',
|
||||
id: 'site-favicon',
|
||||
title: '파비콘',
|
||||
adminUrl: '/admin/settings',
|
||||
publicUrl: '/',
|
||||
status: 'system',
|
||||
location: 'faviconUrl',
|
||||
label: '파비콘'
|
||||
})
|
||||
}
|
||||
|
||||
return usages
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 목록 조회
|
||||
* @returns {Promise<Array<Object>>} 미디어 항목 목록
|
||||
@@ -485,9 +525,10 @@ const getMediaUsage = (url, posts, pages) => {
|
||||
export const listMediaItems = async () => {
|
||||
const items = await readMediaDirectory(uploadRoot)
|
||||
const metadataMap = await getMediaMetadataMap()
|
||||
const [posts, pages] = await Promise.all([
|
||||
const [posts, pages, siteSettings] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
listPages(),
|
||||
getSiteSettings()
|
||||
])
|
||||
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
|
||||
const itemsWithUsage = items.map((item) => {
|
||||
@@ -498,7 +539,10 @@ export const listMediaItems = async () => {
|
||||
return {
|
||||
...item,
|
||||
category,
|
||||
usage: getMediaUsage(item.url, posts, pages),
|
||||
usage: [
|
||||
...getMediaUsage(item.url, posts, pages),
|
||||
...getSiteSettingsMediaUsage(item.url, siteSettings)
|
||||
],
|
||||
avatarOwner
|
||||
}
|
||||
})
|
||||
@@ -615,11 +659,15 @@ export const updateMediaCategories = async (urls, category) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteMediaItem = async (url) => {
|
||||
const [posts, pages] = await Promise.all([
|
||||
const [posts, pages, siteSettings] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
listPages(),
|
||||
getSiteSettings()
|
||||
])
|
||||
const usage = getMediaUsage(url, posts, pages)
|
||||
const usage = [
|
||||
...getMediaUsage(url, posts, pages),
|
||||
...getSiteSettingsMediaUsage(url, siteSettings)
|
||||
]
|
||||
|
||||
if (usage.length) {
|
||||
throw createError({
|
||||
@@ -646,11 +694,15 @@ export const deleteMediaItem = async (url) => {
|
||||
* @returns {Promise<Object>} 변경된 미디어 항목
|
||||
*/
|
||||
export const renameMediaItem = async (url, name) => {
|
||||
const [posts, pages] = await Promise.all([
|
||||
const [posts, pages, siteSettings] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
listPages(),
|
||||
getSiteSettings()
|
||||
])
|
||||
const usage = getMediaUsage(url, posts, pages)
|
||||
const usage = [
|
||||
...getMediaUsage(url, posts, pages),
|
||||
...getSiteSettingsMediaUsage(url, siteSettings)
|
||||
]
|
||||
|
||||
if (usage.length) {
|
||||
throw createError({
|
||||
|
||||
Reference in New Issue
Block a user