From cf0450f51c8bbbd90f2b97456b5ff00e41d86203 Mon Sep 17 00:00:00 2001 From: yoonc01 Date: Fri, 26 Jun 2026 00:58:40 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=8C=80=ED=95=99=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=BA=90=EC=8B=9C=20=EC=9E=AC=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/api/revalidate/route.ts | 135 ++++++++++++++++++ .../university/[homeUniversity]/[id]/page.tsx | 4 +- 2 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 apps/university-web/src/app/api/revalidate/route.ts diff --git a/apps/university-web/src/app/api/revalidate/route.ts b/apps/university-web/src/app/api/revalidate/route.ts new file mode 100644 index 00000000..8ca30a1c --- /dev/null +++ b/apps/university-web/src/app/api/revalidate/route.ts @@ -0,0 +1,135 @@ +import { revalidatePath } from "next/cache"; +import { NextResponse } from "next/server"; + +import { getSearchUniversitiesAllRegions } from "@/apis/universities/server"; +import { getHomeUniversityBySlug, HOME_UNIVERSITY_LIST, HOME_UNIVERSITY_SLUGS } from "@/constants/university"; +import type { HomeUniversitySlug } from "@/types/university"; + +type RevalidateScope = "university" | "home-university" | "path"; + +type RevalidateRequestBody = { + scope?: RevalidateScope; + homeUniversity?: HomeUniversitySlug; + path?: string; + includeDetails?: boolean; +}; + +const getBearerToken = (authorizationHeader: string | null) => { + if (!authorizationHeader?.startsWith("Bearer ")) { + return null; + } + + return authorizationHeader.slice("Bearer ".length).trim(); +}; + +const isAuthorized = (request: Request) => { + const secret = process.env.REVALIDATE_SECRET; + + if (!secret) { + return false; + } + + const requestSecret = + getBearerToken(request.headers.get("authorization")) ?? request.headers.get("x-revalidate-secret"); + + return requestSecret === secret; +}; + +const isAllowedUniversityPath = (path: string) => { + if (!path.startsWith("/university")) { + return false; + } + + if (path.includes("://") || path.includes("..")) { + return false; + } + + return ( + path === "/university" || path === "/university/search" || /^\/university\/[a-z0-9-]+(\/search|\/\d+)?$/.test(path) + ); +}; + +const revalidateUniversityPath = (path: string, revalidatedPaths: Set) => { + revalidatePath(path); + revalidatedPaths.add(path); +}; + +const revalidateHomeUniversityPaths = async ( + homeUniversity: HomeUniversitySlug, + includeDetails: boolean, + revalidatedPaths: Set, +) => { + const homeUniversityInfo = getHomeUniversityBySlug(homeUniversity); + + if (!homeUniversityInfo) { + throw new Error(`Invalid homeUniversity: ${homeUniversity}`); + } + + revalidateUniversityPath(`/university/${homeUniversity}`, revalidatedPaths); + revalidateUniversityPath(`/university/${homeUniversity}/search`, revalidatedPaths); + + if (!includeDetails) { + return; + } + + const universities = await getSearchUniversitiesAllRegions({ + homeUniversityId: homeUniversityInfo.homeUniversityId, + }); + + for (const university of universities) { + revalidateUniversityPath(`/university/${homeUniversity}/${university.id}`, revalidatedPaths); + } +}; + +const parseRequestBody = async (request: Request): Promise => { + try { + return (await request.json()) as RevalidateRequestBody; + } catch { + return {}; + } +}; + +export async function POST(request: Request) { + if (!isAuthorized(request)) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const body = await parseRequestBody(request); + const scope = body.scope ?? "university"; + const includeDetails = body.includeDetails ?? true; + const revalidatedPaths = new Set(); + + try { + if (scope === "path") { + if (!body.path || !isAllowedUniversityPath(body.path)) { + return NextResponse.json({ message: "Invalid revalidation path" }, { status: 400 }); + } + + revalidateUniversityPath(body.path, revalidatedPaths); + } else if (scope === "home-university") { + if (!body.homeUniversity || !HOME_UNIVERSITY_SLUGS.includes(body.homeUniversity)) { + return NextResponse.json({ message: "Invalid homeUniversity" }, { status: 400 }); + } + + await revalidateHomeUniversityPaths(body.homeUniversity, includeDetails, revalidatedPaths); + } else if (scope === "university") { + revalidateUniversityPath("/university", revalidatedPaths); + revalidateUniversityPath("/university/search", revalidatedPaths); + + for (const homeUniversity of HOME_UNIVERSITY_LIST) { + await revalidateHomeUniversityPaths(homeUniversity.slug, includeDetails, revalidatedPaths); + } + } else { + return NextResponse.json({ message: "Invalid revalidation scope" }, { status: 400 }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + + return NextResponse.json({ message: "Failed to revalidate", error: errorMessage }, { status: 500 }); + } + + return NextResponse.json({ + revalidated: true, + paths: Array.from(revalidatedPaths), + }); +} diff --git a/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx index 677e30ce..492dea36 100644 --- a/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx +++ b/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx @@ -12,8 +12,8 @@ import { createUrl, NO_INDEX_ROBOTS } from "@/utils/seo"; import UniversityDetail from "./_ui/UniversityDetail"; import UniversityDetailPreparingFallback from "./_ui/UniversityDetailPreparingFallback"; -export const revalidate = false; // 완전 정적 생성 -export const dynamicParams = false; +export const revalidate = false; +export const dynamicParams = true; // 모든 homeUniversity + id 조합에 대해 정적 경로 생성 export async function generateStaticParams() { From 381041903f5137e34376b3f69243c64f65c9578c Mon Sep 17 00:00:00 2001 From: yoonc01 Date: Fri, 26 Jun 2026 01:03:33 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=EB=8C=80=ED=95=99=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=AC=EA=B2=80=EC=A6=9D=20=EB=B0=A9=EB=B2=95=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/README.md b/README.md index 1dfa1c1f..1709ffdf 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,68 @@ rm -rf node_modules package-lock.json pnpm install ``` +## University Web Revalidation + +대학 지원 정보가 변경된 뒤 배포 없이 `university-web`의 정적 캐시를 무효화하려면 `POST /api/revalidate`를 호출한다. + +### Environment + +Vercel의 `solid-connect-university-web` 프로젝트에 다음 환경 변수가 필요하다. + +```bash +REVALIDATE_SECRET=your-secret +``` + +### Authentication + +다음 두 방식 중 하나로 secret을 전달할 수 있다. + +```bash +Authorization: Bearer ${REVALIDATE_SECRET} +``` + +```bash +x-revalidate-secret: ${REVALIDATE_SECRET} +``` + +### Revalidate All University Pages + +```bash +curl -X POST "https://www.solid-connection.com/api/revalidate" \ + -H "Authorization: Bearer ${REVALIDATE_SECRET}" \ + -H "Content-Type: application/json" \ + --data '{"scope":"university"}' +``` + +### Revalidate Home University Pages + +```bash +curl -X POST "https://www.solid-connection.com/api/revalidate" \ + -H "Authorization: Bearer ${REVALIDATE_SECRET}" \ + -H "Content-Type: application/json" \ + --data '{"scope":"home-university","homeUniversity":"kyunghee"}' +``` + +### Revalidate One Path + +```bash +curl -X POST "https://www.solid-connection.com/api/revalidate" \ + -H "Authorization: Bearer ${REVALIDATE_SECRET}" \ + -H "Content-Type: application/json" \ + --data '{"scope":"path","path":"/university/kyunghee"}' +``` + +### Response + +```json +{ + "revalidated": true, + "paths": ["/university/kyunghee", "/university/kyunghee/search"] +} +``` + +`revalidatePath`는 캐시를 무효화한다. 새 HTML은 API 호출 시점이 아니라 해당 페이지에 다음 요청이 들어올 때 다시 생성된다. + ## AI Inspector Workflow Admin 계정일 때 좌측 하단에 AI 인스펙터 플로팅 버튼이 노출됩니다. From debd87b5101c408499de28a03e7fd7b6fdf47adc Mon Sep 17 00:00:00 2001 From: yoonc01 Date: Fri, 26 Jun 2026 01:10:49 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=8C=80=ED=95=99=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EC=A0=81=20=EA=B2=BD=EB=A1=9C=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/university/[homeUniversity]/[id]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx b/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx index 492dea36..29cc0e7c 100644 --- a/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx +++ b/apps/university-web/src/app/university/[homeUniversity]/[id]/page.tsx @@ -13,7 +13,7 @@ import UniversityDetail from "./_ui/UniversityDetail"; import UniversityDetailPreparingFallback from "./_ui/UniversityDetailPreparingFallback"; export const revalidate = false; -export const dynamicParams = true; +export const dynamicParams = false; // 모든 homeUniversity + id 조합에 대해 정적 경로 생성 export async function generateStaticParams() {