- backend/routers/reports.py: project.name을 project.project_name으로 수정 (3곳) - 일일보고서 엑셀 내보내기 오류 해결 - 배포 가이드 업데이트 (DEPLOYMENT_GUIDE_20251028.md) - 프로젝트 속성명 불일치로 인한 500 에러 해결 Fixes: 'Project' object has no attribute 'name' 오류
336 lines
8.9 KiB
JavaScript
336 lines
8.9 KiB
JavaScript
/**
|
|
* 서비스 워커 - 페이지 및 리소스 캐싱
|
|
* M-Project 작업보고서 시스템
|
|
*/
|
|
|
|
const CACHE_NAME = 'mproject-v1.0.1';
|
|
const STATIC_CACHE = 'mproject-static-v1.0.1';
|
|
const DYNAMIC_CACHE = 'mproject-dynamic-v1.0.1';
|
|
|
|
// 캐시할 정적 리소스
|
|
const STATIC_ASSETS = [
|
|
'/',
|
|
'/index.html',
|
|
'/issue-view.html',
|
|
'/daily-work.html',
|
|
'/project-management.html',
|
|
'/admin.html',
|
|
'/static/js/api.js',
|
|
'/static/js/core/permissions.js',
|
|
'/static/js/components/common-header.js',
|
|
'/static/js/core/page-manager.js',
|
|
'/static/js/core/page-preloader.js',
|
|
'/static/js/date-utils.js',
|
|
'/static/js/image-utils.js',
|
|
'https://cdn.tailwindcss.com',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css'
|
|
];
|
|
|
|
// 캐시 전략별 URL 패턴
|
|
const CACHE_STRATEGIES = {
|
|
// 네트워크 우선 (API 호출)
|
|
networkFirst: [
|
|
/\/api\//,
|
|
/\/auth\//
|
|
],
|
|
|
|
// 캐시 우선 (정적 리소스)
|
|
cacheFirst: [
|
|
/\.css$/,
|
|
/\.js$/,
|
|
/\.png$/,
|
|
/\.jpg$/,
|
|
/\.jpeg$/,
|
|
/\.gif$/,
|
|
/\.svg$/,
|
|
/\.woff$/,
|
|
/\.woff2$/,
|
|
/cdn\.tailwindcss\.com/,
|
|
/cdnjs\.cloudflare\.com/
|
|
],
|
|
|
|
// 스테일 허용 (HTML 페이지)
|
|
staleWhileRevalidate: [
|
|
/\.html$/,
|
|
/\/$/
|
|
]
|
|
};
|
|
|
|
/**
|
|
* 서비스 워커 설치
|
|
*/
|
|
self.addEventListener('install', (event) => {
|
|
console.log('🔧 서비스 워커 설치 중...');
|
|
|
|
event.waitUntil(
|
|
caches.open(STATIC_CACHE)
|
|
.then((cache) => {
|
|
console.log('📦 정적 리소스 캐싱 중...');
|
|
return cache.addAll(STATIC_ASSETS);
|
|
})
|
|
.then(() => {
|
|
console.log('✅ 서비스 워커 설치 완료');
|
|
return self.skipWaiting();
|
|
})
|
|
.catch((error) => {
|
|
console.error('❌ 서비스 워커 설치 실패:', error);
|
|
})
|
|
);
|
|
});
|
|
|
|
/**
|
|
* 서비스 워커 활성화
|
|
*/
|
|
self.addEventListener('activate', (event) => {
|
|
console.log('🚀 서비스 워커 활성화 중...');
|
|
|
|
event.waitUntil(
|
|
caches.keys()
|
|
.then((cacheNames) => {
|
|
return Promise.all(
|
|
cacheNames.map((cacheName) => {
|
|
// 이전 버전 캐시 삭제
|
|
if (cacheName !== STATIC_CACHE &&
|
|
cacheName !== DYNAMIC_CACHE &&
|
|
cacheName !== CACHE_NAME) {
|
|
console.log('🗑️ 이전 캐시 삭제:', cacheName);
|
|
return caches.delete(cacheName);
|
|
}
|
|
})
|
|
);
|
|
})
|
|
.then(() => {
|
|
console.log('✅ 서비스 워커 활성화 완료');
|
|
return self.clients.claim();
|
|
})
|
|
);
|
|
});
|
|
|
|
/**
|
|
* 네트워크 요청 가로채기
|
|
*/
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// CORS 요청이나 외부 도메인은 기본 처리
|
|
if (url.origin !== location.origin && !isCDNResource(url)) {
|
|
return;
|
|
}
|
|
|
|
// 캐시 전략 결정
|
|
const strategy = getCacheStrategy(request.url);
|
|
|
|
event.respondWith(
|
|
handleRequest(request, strategy)
|
|
);
|
|
});
|
|
|
|
/**
|
|
* 요청 처리 (캐시 전략별)
|
|
*/
|
|
async function handleRequest(request, strategy) {
|
|
try {
|
|
switch (strategy) {
|
|
case 'networkFirst':
|
|
return await networkFirst(request);
|
|
case 'cacheFirst':
|
|
return await cacheFirst(request);
|
|
case 'staleWhileRevalidate':
|
|
return await staleWhileRevalidate(request);
|
|
default:
|
|
return await fetch(request);
|
|
}
|
|
} catch (error) {
|
|
console.error('요청 처리 실패:', request.url, error);
|
|
return await handleOffline(request);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 네트워크 우선 전략
|
|
*/
|
|
async function networkFirst(request) {
|
|
try {
|
|
const networkResponse = await fetch(request);
|
|
|
|
// 성공적인 응답만 캐시
|
|
if (networkResponse.ok) {
|
|
const cache = await caches.open(DYNAMIC_CACHE);
|
|
cache.put(request, networkResponse.clone());
|
|
}
|
|
|
|
return networkResponse;
|
|
} catch (error) {
|
|
// 네트워크 실패 시 캐시에서 반환
|
|
const cachedResponse = await caches.match(request);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 캐시 우선 전략
|
|
*/
|
|
async function cacheFirst(request) {
|
|
const cachedResponse = await caches.match(request);
|
|
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
// 캐시에 없으면 네트워크에서 가져와서 캐시
|
|
const networkResponse = await fetch(request);
|
|
|
|
if (networkResponse.ok) {
|
|
const cache = await caches.open(STATIC_CACHE);
|
|
cache.put(request, networkResponse.clone());
|
|
}
|
|
|
|
return networkResponse;
|
|
}
|
|
|
|
/**
|
|
* 스테일 허용 전략
|
|
*/
|
|
async function staleWhileRevalidate(request) {
|
|
const cachedResponse = await caches.match(request);
|
|
|
|
// 백그라운드에서 업데이트
|
|
const networkResponsePromise = fetch(request)
|
|
.then((networkResponse) => {
|
|
if (networkResponse.ok) {
|
|
const cache = caches.open(DYNAMIC_CACHE);
|
|
cache.then(c => c.put(request, networkResponse.clone()));
|
|
}
|
|
return networkResponse;
|
|
})
|
|
.catch(() => null);
|
|
|
|
// 캐시된 응답이 있으면 즉시 반환, 없으면 네트워크 대기
|
|
return cachedResponse || await networkResponsePromise;
|
|
}
|
|
|
|
/**
|
|
* 캐시 전략 결정
|
|
*/
|
|
function getCacheStrategy(url) {
|
|
for (const [strategy, patterns] of Object.entries(CACHE_STRATEGIES)) {
|
|
if (patterns.some(pattern => pattern.test(url))) {
|
|
return strategy;
|
|
}
|
|
}
|
|
return 'networkFirst'; // 기본값
|
|
}
|
|
|
|
/**
|
|
* CDN 리소스 확인
|
|
*/
|
|
function isCDNResource(url) {
|
|
const cdnDomains = [
|
|
'cdn.tailwindcss.com',
|
|
'cdnjs.cloudflare.com',
|
|
'fonts.googleapis.com',
|
|
'fonts.gstatic.com'
|
|
];
|
|
|
|
return cdnDomains.some(domain => url.hostname.includes(domain));
|
|
}
|
|
|
|
/**
|
|
* 오프라인 처리
|
|
*/
|
|
async function handleOffline(request) {
|
|
// HTML 요청에 대한 오프라인 페이지
|
|
if (request.destination === 'document') {
|
|
const offlinePage = await caches.match('/index.html');
|
|
if (offlinePage) {
|
|
return offlinePage;
|
|
}
|
|
}
|
|
|
|
// 이미지 요청에 대한 기본 이미지
|
|
if (request.destination === 'image') {
|
|
return new Response(
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200"><rect width="200" height="200" fill="#f3f4f6"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="#9ca3af">오프라인</text></svg>',
|
|
{ headers: { 'Content-Type': 'image/svg+xml' } }
|
|
);
|
|
}
|
|
|
|
// 기본 오프라인 응답
|
|
return new Response('오프라인 상태입니다.', {
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 메시지 처리 (캐시 관리)
|
|
*/
|
|
self.addEventListener('message', (event) => {
|
|
const { type, payload } = event.data;
|
|
|
|
switch (type) {
|
|
case 'CLEAR_CACHE':
|
|
clearAllCaches().then(() => {
|
|
event.ports[0].postMessage({ success: true });
|
|
});
|
|
break;
|
|
|
|
case 'CACHE_PAGE':
|
|
cachePage(payload.url).then(() => {
|
|
event.ports[0].postMessage({ success: true });
|
|
});
|
|
break;
|
|
|
|
case 'GET_CACHE_STATUS':
|
|
getCacheStatus().then((status) => {
|
|
event.ports[0].postMessage({ status });
|
|
});
|
|
break;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 모든 캐시 정리
|
|
*/
|
|
async function clearAllCaches() {
|
|
const cacheNames = await caches.keys();
|
|
await Promise.all(
|
|
cacheNames.map(cacheName => caches.delete(cacheName))
|
|
);
|
|
console.log('🗑️ 모든 캐시 정리 완료');
|
|
}
|
|
|
|
/**
|
|
* 특정 페이지 캐시
|
|
*/
|
|
async function cachePage(url) {
|
|
try {
|
|
const cache = await caches.open(DYNAMIC_CACHE);
|
|
await cache.add(url);
|
|
console.log('📦 페이지 캐시 완료:', url);
|
|
} catch (error) {
|
|
console.error('페이지 캐시 실패:', url, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 캐시 상태 조회
|
|
*/
|
|
async function getCacheStatus() {
|
|
const cacheNames = await caches.keys();
|
|
const status = {};
|
|
|
|
for (const cacheName of cacheNames) {
|
|
const cache = await caches.open(cacheName);
|
|
const keys = await cache.keys();
|
|
status[cacheName] = keys.length;
|
|
}
|
|
|
|
return status;
|
|
}
|