@@ -3,7 +3,7 @@ import { safeJsonLdString } from "@/lib/json-ld";
33import { SITE_URL } from "@/lib/site-url" ;
44import { ensureSeoDescription } from "@/lib/seo-description" ;
55import { DocsPage , DocsBody } from "fumadocs-ui/page" ;
6- import { notFound } from "next/navigation" ;
6+ import { notFound , permanentRedirect } from "next/navigation" ;
77import type { Metadata } from "next" ;
88import { setRequestLocale } from "next-intl/server" ;
99import { hasLocale } from "next-intl" ;
@@ -24,18 +24,50 @@ import { DocShareButton } from "@/app/components/DocShareButton";
2424import { routing } from "@/i18n/routing" ;
2525import { type PageData } from "@/app/types/doc" ;
2626
27+ const BACKEND_URL = process . env . BACKEND_URL ?? "http://localhost:8080" ;
28+
29+ /**
30+ * 查询后端 resolve 端点,未知路径可能是历史重命名路径。
31+ * 返回 canonical URL(如 /docs/learn/cs/dev-tips/git101)或 null。
32+ * 后端 Caffeine 缓存 TTL=600s,命中率高,延迟可控。
33+ */
34+ async function resolveDocPath (
35+ locale : string ,
36+ slug : string [ ] ,
37+ ) : Promise < string | null > {
38+ const strippedPath = `/docs/${ slug . join ( "/" ) } ` ;
39+ try {
40+ const controller = new AbortController ( ) ;
41+ const timeout = setTimeout ( ( ) => controller . abort ( ) , 400 ) ;
42+ const res = await fetch (
43+ `${ BACKEND_URL } /api/docs/resolve?path=${ encodeURIComponent ( strippedPath ) } ` ,
44+ { redirect : "manual" , signal : controller . signal , cache : "no-store" } ,
45+ ) ;
46+ clearTimeout ( timeout ) ;
47+ if ( res . status === 301 || res . status === 308 ) {
48+ const loc = res . headers . get ( "Location" ) ;
49+ // loc === strippedPath 意味着当前路径已是 canonical,不跳
50+ if ( loc && loc !== strippedPath ) {
51+ return `/${ locale } ${ loc } ` ;
52+ }
53+ }
54+ } catch {
55+ // 超时或后端不可达:降级到 notFound()
56+ }
57+ return null ;
58+ }
59+
2760interface Param {
2861 params : Promise < {
2962 locale : string ;
3063 slug ?: string [ ] ;
3164 } > ;
3265}
3366
34- // 显式声明 force-static:让 Next.js 严格按 generateStaticParams 预渲染
35- // 所有 (locale, slug) 组合,未列出的不允许动态生成。
36- // 没有这条时,build 表里 ƒ Dynamic 标签会让 docs 走运行时渲染(即使加了
37- // setRequestLocale 也不一定 prerender)。
38- export const dynamic = "force-static" ;
67+ // dynamicParams=true(默认值):generateStaticParams 列出的路径 SSG 预渲染,
68+ // 未列出的路径(包括历史旧路径)走运行时 SSR,在 DocPage 里做 resolve → permanentRedirect。
69+ // 不写 dynamic="force-static":让未知路径能够 SSR,而不是直接 404。
70+ // 已知路径仍然走 SSG(generateStaticParams 命中),正常页面 TTFB 不受影响。
3971
4072export default async function DocPage ( { params } : Param ) {
4173 const { locale, slug } = await params ;
@@ -47,6 +79,11 @@ export default async function DocPage({ params }: Param) {
4779 // 找不到时按 source.ts 配的 fallbackLanguage='zh' 回退到原文。
4880 const page = source . getPage ( slug , locale ) ;
4981 if ( page == null ) {
82+ // slug 不在 SSG 列表里:查历史路径表,命中则服务端 308(Googlebot 可跟)
83+ const redirectTarget = await resolveDocPath ( locale , slug ?? [ ] ) ;
84+ if ( redirectTarget ) {
85+ permanentRedirect ( redirectTarget ) ;
86+ }
5087 notFound ( ) ;
5188 }
5289
@@ -181,6 +218,11 @@ export async function generateMetadata({ params }: Param): Promise<Metadata> {
181218
182219 const page = source . getPage ( slug , locale ) ;
183220 if ( page == null ) {
221+ // generateMetadata 同步 page.tsx 的跳转逻辑,避免 metadata 和页面不一致
222+ const redirectTarget = await resolveDocPath ( locale , slug ?? [ ] ) ;
223+ if ( redirectTarget ) {
224+ permanentRedirect ( redirectTarget ) ;
225+ }
184226 notFound ( ) ;
185227 }
186228
0 commit comments