-
This commit is contained in:
@@ -144,106 +144,133 @@ function tryServeStatic(req, res, pathname) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
||||||
let rawPathname;
|
|
||||||
try {
|
try {
|
||||||
rawPathname = decodeURIComponent(reqUrl.pathname);
|
let rawPathname = '/';
|
||||||
} catch {
|
|
||||||
rawPathname = reqUrl.pathname;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 헬스체크 엔드포인트 (Docker / Nginx Proxy Manager 상태 확인용)
|
|
||||||
if (rawPathname === '/healthz') {
|
|
||||||
sendResponse(req, res, 200, 'text/plain; charset=utf-8', 'OK', {
|
|
||||||
'Cache-Control': 'no-store'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 정적 파일 우선 서빙 (/assets/*, /img/*, /sitemap*.xml, /robots.txt, /favicon.svg 등, 단 '/' 제외)
|
|
||||||
if (rawPathname !== '/' && tryServeStatic(req, res, rawPathname)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 끝 슬래시 정규화 (루트 '/' 제외)
|
|
||||||
const pathname = rawPathname.length > 1 && rawPathname.endsWith('/')
|
|
||||||
? rawPathname.replace(/\/+$/, '')
|
|
||||||
: rawPathname;
|
|
||||||
|
|
||||||
const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/') || pathname.endsWith('/admin');
|
|
||||||
|
|
||||||
// 2. 캐시 확인 (인메모리 캐시 히트 시 0.001초 응답, 관리자 경로 제외)
|
|
||||||
if (!isAdmin) {
|
|
||||||
const cached = getCachedHtml(pathname);
|
|
||||||
if (cached) {
|
|
||||||
sendResponse(req, res, cached.statusCode, 'text/html; charset=utf-8', cached.html, {
|
|
||||||
'X-Cache': 'HIT',
|
|
||||||
'Cache-Control': 'public, max-age=3600'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 온디맨드 실시간 SSR 렌더링 (전국 50,380개 지역 페이지 전체 200 OK 및 고유 메타·본문 생성)
|
|
||||||
if (ssrRender && template) {
|
|
||||||
try {
|
try {
|
||||||
const { html, head, routeName } = await ssrRender(pathname);
|
const safeHost = (req.headers.host || 'localhost').replace(/[^a-zA-Z0-9.:-]/g, '');
|
||||||
|
const reqUrl = new URL(req.url || '/', `http://${safeHost || 'localhost'}`);
|
||||||
|
rawPathname = decodeURIComponent(reqUrl.pathname);
|
||||||
|
} catch {
|
||||||
|
rawPathname = (req.url || '/').split('?')[0] || '/';
|
||||||
|
}
|
||||||
|
|
||||||
if (routeName === 'NotFound') {
|
// 헬스체크 엔드포인트 (Docker / Nginx Proxy Manager 상태 확인용)
|
||||||
let notFoundHtml = template;
|
if (rawPathname === '/healthz') {
|
||||||
notFoundHtml = notFoundHtml.replace(/<title\s+data-default[\s\S]*?<\/title>\s*/i, '');
|
sendResponse(req, res, 200, 'text/plain; charset=utf-8', 'OK', {
|
||||||
notFoundHtml = notFoundHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, '');
|
'Cache-Control': 'no-store'
|
||||||
notFoundHtml = notFoundHtml
|
|
||||||
.replace(HEAD_PLACEHOLDER, head || '<title>페이지를 찾을 수 없습니다</title><meta name="robots" content="noindex" />')
|
|
||||||
.replace(HTML_PLACEHOLDER, html || '');
|
|
||||||
sendResponse(req, res, 404, 'text/html; charset=utf-8', notFoundHtml, {
|
|
||||||
'X-Robots-Tag': 'noindex, nofollow',
|
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 정상 완성형 HTML 조립
|
|
||||||
let fullHtml = template;
|
|
||||||
if (head) {
|
|
||||||
fullHtml = fullHtml.replace(/<title\s+data-default[\s\S]*?<\/title>\s*/i, '');
|
|
||||||
fullHtml = fullHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, '');
|
|
||||||
fullHtml = fullHtml.replace(HEAD_PLACEHOLDER, head);
|
|
||||||
} else {
|
|
||||||
fullHtml = fullHtml.replace(HEAD_PLACEHOLDER, '');
|
|
||||||
}
|
|
||||||
fullHtml = fullHtml.replace(HTML_PLACEHOLDER, html);
|
|
||||||
|
|
||||||
if (isAdmin || routeName === 'AdminHome' || routeName === 'LocationSubjectFullAdmin') {
|
|
||||||
sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, {
|
|
||||||
'X-Robots-Tag': 'noindex, nofollow',
|
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 캐시에 저장 (다음 요청부터 0.001초 응답)
|
|
||||||
setCachedHtml(pathname, { statusCode: 200, html: fullHtml });
|
|
||||||
|
|
||||||
sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, {
|
|
||||||
'X-Cache': 'MISS',
|
|
||||||
'Cache-Control': 'public, max-age=3600'
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
|
||||||
console.error(`[SSR Error] ${pathname} 렌더링 중 오류:`, err);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 예외 시 SPA 기본 index.html 폴백 서빙
|
// 브라우저 기본 /favicon.ico 요청 시 정적 아이콘으로 즉시 응답 (불필요한 SSR/404 방지)
|
||||||
if (template) {
|
if (rawPathname === '/favicon.ico') {
|
||||||
sendResponse(req, res, 200, 'text/html; charset=utf-8', template);
|
if (tryServeStatic(req, res, '/img/pavicon.png') || tryServeStatic(req, res, '/favicon.svg')) {
|
||||||
} else {
|
return;
|
||||||
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
}
|
||||||
res.end('서버 빌드 산출물이 존재하지 않습니다. npm run build:fast 를 먼저 실행해주세요.');
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 정적 파일 우선 서빙 (/assets/*, /img/*, /sitemap*.xml, /robots.txt, /favicon.svg 등, 단 '/' 제외)
|
||||||
|
if (rawPathname !== '/' && tryServeStatic(req, res, rawPathname)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 끝 슬래시 정규화 (루트 '/' 제외)
|
||||||
|
const pathname = rawPathname.length > 1 && rawPathname.endsWith('/')
|
||||||
|
? rawPathname.replace(/\/+$/, '')
|
||||||
|
: rawPathname;
|
||||||
|
|
||||||
|
const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/') || pathname.endsWith('/admin');
|
||||||
|
|
||||||
|
// 2. 캐시 확인 (인메모리 캐시 히트 시 0.001초 응답, 관리자 경로 제외)
|
||||||
|
if (!isAdmin) {
|
||||||
|
const cached = getCachedHtml(pathname);
|
||||||
|
if (cached) {
|
||||||
|
sendResponse(req, res, cached.statusCode, 'text/html; charset=utf-8', cached.html, {
|
||||||
|
'X-Cache': 'HIT',
|
||||||
|
'Cache-Control': 'public, max-age=3600'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 온디맨드 실시간 SSR 렌더링 (전국 50,380개 지역 페이지 전체 200 OK 및 고유 메타·본문 생성)
|
||||||
|
if (ssrRender && template) {
|
||||||
|
try {
|
||||||
|
const { html, head, routeName } = await ssrRender(pathname);
|
||||||
|
|
||||||
|
if (routeName === 'NotFound') {
|
||||||
|
let notFoundHtml = template;
|
||||||
|
notFoundHtml = notFoundHtml.replace(/<title\s+data-default[\s\S]*?<\/title>\s*/i, '');
|
||||||
|
notFoundHtml = notFoundHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, '');
|
||||||
|
notFoundHtml = notFoundHtml
|
||||||
|
.replace(HEAD_PLACEHOLDER, head || '<title>페이지를 찾을 수 없습니다</title><meta name="robots" content="noindex" />')
|
||||||
|
.replace(HTML_PLACEHOLDER, html || '');
|
||||||
|
sendResponse(req, res, 404, 'text/html; charset=utf-8', notFoundHtml, {
|
||||||
|
'X-Robots-Tag': 'noindex, nofollow',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정상 완성형 HTML 조립
|
||||||
|
let fullHtml = template;
|
||||||
|
if (head) {
|
||||||
|
fullHtml = fullHtml.replace(/<title\s+data-default[\s\S]*?<\/title>\s*/i, '');
|
||||||
|
fullHtml = fullHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, '');
|
||||||
|
fullHtml = fullHtml.replace(HEAD_PLACEHOLDER, head);
|
||||||
|
} else {
|
||||||
|
fullHtml = fullHtml.replace(HEAD_PLACEHOLDER, '');
|
||||||
|
}
|
||||||
|
fullHtml = fullHtml.replace(HTML_PLACEHOLDER, html);
|
||||||
|
|
||||||
|
if (isAdmin || routeName === 'AdminHome' || routeName === 'LocationSubjectFullAdmin') {
|
||||||
|
sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, {
|
||||||
|
'X-Robots-Tag': 'noindex, nofollow',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캐시에 저장 (다음 요청부터 0.001초 응답)
|
||||||
|
setCachedHtml(pathname, { statusCode: 200, html: fullHtml });
|
||||||
|
|
||||||
|
sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, {
|
||||||
|
'X-Cache': 'MISS',
|
||||||
|
'Cache-Control': 'public, max-age=3600'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[SSR Error] ${pathname} 렌더링 중 오류:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 예외 시 SPA 기본 index.html 폴백 서빙
|
||||||
|
if (template) {
|
||||||
|
sendResponse(req, res, 200, 'text/html; charset=utf-8', template);
|
||||||
|
} else {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('서버 빌드 산출물이 존재하지 않습니다. npm run build:fast 를 먼저 실행해주세요.');
|
||||||
|
}
|
||||||
|
} catch (fatalErr) {
|
||||||
|
console.error('[HTTP Handler Error]:', fatalErr);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
}
|
||||||
|
res.end('Internal Server Error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 비정상 봇/스캐너 패킷으로 인한 프로세스 종료 원천 차단
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
console.error('[Uncaught Exception]:', err);
|
||||||
|
});
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
console.error('[Unhandled Rejection]:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
await loadSsrEntry();
|
await loadSsrEntry();
|
||||||
|
|
||||||
server.listen(PORT, HOST, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
|
|||||||
@@ -275,7 +275,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 하단 Pager 내부 링크 클러스터 (동일 동 10개 과목·학년 + 동일 구·군 내 다른 동 + 동일 시·도 내 다른 구·군 연결) -->
|
<!-- 하단 Pager 내부 링크 클러스터 (주석 처리)
|
||||||
<nav class="pager" aria-label="지역 및 과목별 과외 페이지 이동">
|
<nav class="pager" aria-label="지역 및 과목별 과외 페이지 이동">
|
||||||
<div class="pager-group">
|
<div class="pager-group">
|
||||||
<strong class="pager-group-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} 학년·과목별 과외 바로가기</strong>
|
<strong class="pager-group-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} 학년·과목별 과외 바로가기</strong>
|
||||||
@@ -314,6 +314,7 @@
|
|||||||
|
|
||||||
<router-link v-if="isAdmin" to="/regions" class="pager-link pager-next">전국 지역 목록 보기 →</router-link>
|
<router-link v-if="isAdmin" to="/regions" class="pager-link pager-next">전국 지역 목록 보기 →</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
|
-->
|
||||||
|
|
||||||
<!-- 하단 가로형 과목 이미지 캐러셀 (네이버 검색 결과 가로 멀티 썸네일 노출 최적화) -->
|
<!-- 하단 가로형 과목 이미지 캐러셀 (네이버 검색 결과 가로 멀티 썸네일 노출 최적화) -->
|
||||||
<section class="bottom-carousel-section" itemscope itemtype="https://schema.org/ImageGallery" aria-label="과목별 과외 가로 이미지 캐러셀">
|
<section class="bottom-carousel-section" itemscope itemtype="https://schema.org/ImageGallery" aria-label="과목별 과외 가로 이미지 캐러셀">
|
||||||
@@ -1562,10 +1563,10 @@ watch([() => route.params], () => {
|
|||||||
border-color: var(--border-primary);
|
border-color: var(--border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 하단 가로형 과목 이미지 캐러셀 (네이버 가로 멀티 썸네일 최적화) */
|
/* 하단 가로형 과목 이미지 캐러셀 (네이버 가로 멀티 썸네일 최적화 - 가로 스크롤 없이 비율 자동 맞춤) */
|
||||||
.bottom-carousel-section {
|
.bottom-carousel-section {
|
||||||
margin-bottom: 36px;
|
margin-bottom: 36px;
|
||||||
padding: 22px;
|
padding: 20px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -1579,27 +1580,14 @@ watch([() => route.params], () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.horizontal-carousel-track {
|
.horizontal-carousel-track {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: row;
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
overflow-x: auto;
|
width: 100%;
|
||||||
padding-bottom: 8px;
|
|
||||||
scroll-snap-type: x mandatory;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal-carousel-track::-webkit-scrollbar {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal-carousel-track::-webkit-scrollbar-thumb {
|
|
||||||
background: #cbd5e1;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-carousel-card {
|
.h-carousel-card {
|
||||||
flex: 0 0 165px;
|
min-width: 0;
|
||||||
scroll-snap-align: start;
|
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1.5px solid var(--border-color);
|
border: 1.5px solid var(--border-color);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -1619,7 +1607,9 @@ watch([() => route.params], () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.h-carousel-link {
|
.h-carousel-link {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
@@ -1645,22 +1635,27 @@ watch([() => route.params], () => {
|
|||||||
left: 6px;
|
left: 6px;
|
||||||
background: rgba(15, 23, 42, 0.8);
|
background: rgba(15, 23, 42, 0.8);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 10.5px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 7px;
|
padding: 2px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
max-width: calc(100% - 12px);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-carousel-body {
|
.h-carousel-body {
|
||||||
padding: 10px 12px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-carousel-body h3 {
|
.h-carousel-body h3 {
|
||||||
font-size: 13.5px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-carousel-body p {
|
.h-carousel-body p {
|
||||||
@@ -1674,6 +1669,19 @@ watch([() => route.params], () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.horizontal-carousel-track {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.horizontal-carousel-track {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 빠른 상담 리소스 링크 */
|
/* 빠른 상담 리소스 링크 */
|
||||||
.post-resource-links {
|
.post-resource-links {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user