feat: 可配置站点 URL 风格

- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
This commit is contained in:
2026-09-27 04:01:42 +08:00
parent 1d862a0dd3
commit 7a44c1f2cf
64 changed files with 1183 additions and 200 deletions

View File

@@ -1,5 +1,16 @@
import { NextResponse, type NextRequest } from "next/server";
import { TOKEN_COOKIE, REFRESH_COOKIE, CSRF_COOKIE } from "@/lib/cookies";
import {
entityPath,
leaderboardPath,
matchPrettyPath,
matchStandardPath,
normalizeUrlStyle,
pagePathFromSlug,
standardEntityPath,
type PageKind,
type UrlStyle,
} from "@/lib/urlStyle";
// SSR 登录态保障:
// access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转,
@@ -129,11 +140,14 @@ function parseCookiePair(setCookie: string): [string, string] | null {
return [pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()];
}
/** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉 */
function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }) {
/** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉;styleRewrite 时透传改写后的内部路径 */
function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }, styleRewrite?: URL) {
const headers = new Headers(init?.request?.headers ?? req.headers);
headers.set("x-pathname", req.nextUrl.pathname);
return NextResponse.next({ request: { headers } });
headers.set("x-pathname", (styleRewrite ?? req.nextUrl).pathname);
const res = styleRewrite
? NextResponse.rewrite(styleRewrite, { request: { headers } })
: NextResponse.next({ request: { headers } });
return res;
}
// RFC 9309 规定爬虫文件必须是小写 /robots.txt。部分 SEO 检测工具会请求
@@ -147,16 +161,91 @@ function rewriteRobotsCase(req: NextRequest): NextResponse | null {
return NextResponse.rewrite(url);
}
// ---- 伪静态 URL 风格改写(模板定义见 lib/urlStyle.ts)----
// 风格来自 /api/site-state(与维护态共用 3s 短缓存):
// - 非 default:当前风格 pretty 路径 → rewrite 回标准内部路由(继续走 token 轮转,
// 不得提前 return);标准路径或其它风格 pretty 路径 → 301 到当前风格(SEO 收敛,
// query 原样保留;301 优先于维护态 503,对爬虫语义正确)。
// - default:任何 pretty 路径 → 301 回标准路径。
/** 廉价预判:只对可能命中的路径拉 site-state,/admin、/login 等不产生额外请求 */
function couldBeEntityPath(pathname: string): boolean {
return (
pathname.startsWith("/post/") ||
pathname.startsWith("/u/") ||
pathname.startsWith("/announcement/") ||
pathname.startsWith("/p/") ||
pathname.startsWith("/leaderboard") ||
pathname.endsWith(".html")
);
}
type UrlStyleAction = { type: "pass" } | { type: "redirect"; target: string } | { type: "rewrite"; target: string };
/** 反解结果 → 标准内部路由路径(rewrite 落点);page 的 slug 保持 encoded 原样 */
function standardTarget(m: { kind: PageKind; id?: number; slug?: string }): string {
if (m.kind === "page") return `/p/${m.slug ?? ""}`;
if (m.kind === "leaderboard") return "/leaderboard";
return standardEntityPath(m.kind, m.id ?? 0);
}
/** 反解结果 → 对应风格路径(301 目标);page 用 encoded slug 直接拼,避免二次编码 */
function styledPath(style: UrlStyle, m: { kind: PageKind; id?: number; slug?: string }): string {
if (m.kind === "page") return pagePathFromSlug(style, m.slug ?? "");
if (m.kind === "leaderboard") return leaderboardPath(style);
return entityPath(style, m.kind, m.id ?? 0);
}
function resolveUrlStyleAction(style: UrlStyle, pathname: string): UrlStyleAction {
const pretty = matchPrettyPath(pathname);
const standard = pretty ? null : matchStandardPath(pathname);
if (!pretty && !standard) return { type: "pass" };
if (style === "default") {
if (!pretty) return { type: "pass" };
return { type: "redirect", target: standardTarget(pretty) };
}
if (pretty) {
if (pretty.style === style) {
return { type: "rewrite", target: standardTarget(pretty) };
}
return { type: "redirect", target: styledPath(style, pretty) };
}
// 标准路径 → 301 到当前风格
return { type: "redirect", target: styledPath(style, standard!) };
}
/** 301 目标只换 pathname,origin 与 query(?tab=/?page=)原样保留 */
function urlStyleRedirect(req: NextRequest, targetPath: string): NextResponse {
const url = req.nextUrl.clone();
url.pathname = targetPath;
return NextResponse.redirect(url, 301);
}
async function sessionMiddleware(req: NextRequest) {
const robotsRewrite = rewriteRobotsCase(req);
if (robotsRewrite) return robotsRewrite;
// 伪静态风格改写:rewrite 产物继续走下方 token 轮转(styleRewrite 传给 withPathname),
// 301 直接返回(优先于维护态,语义正确)
let styleRewrite: URL | undefined;
if (couldBeEntityPath(req.nextUrl.pathname)) {
const state = await getCachedPublicSiteState();
const action = resolveUrlStyleAction(normalizeUrlStyle(state?.url_style), req.nextUrl.pathname);
if (action.type === "redirect") return urlStyleRedirect(req, action.target);
if (action.type === "rewrite") {
styleRewrite = req.nextUrl.clone();
styleRewrite.pathname = action.target;
}
}
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
const accessToken = req.cookies.get(TOKEN_COOKIE)?.value;
// 游客、access 仍有效、或服务端地址未配置:直接放行
if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) {
return withPathname(req);
return withPathname(req, undefined, styleRewrite);
}
const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? "";
@@ -175,7 +264,7 @@ async function sessionMiddleware(req: NextRequest) {
const headers = new Headers(req.headers);
headers.set("Cookie", [...jar].map(([k, v]) => `${k}=${v}`).join("; "));
const res = withPathname(req, { request: { headers } });
const res = withPathname(req, { request: { headers } }, styleRewrite);
// 原样透传,HttpOnly/Path/SameSite/Secure 等属性全部以后端为准
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
return res;
@@ -183,7 +272,7 @@ async function sessionMiddleware(req: NextRequest) {
if (setCookies.length > 0) {
// refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转
const res = withPathname(req);
const res = withPathname(req, undefined, styleRewrite);
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
return res;
}
@@ -191,7 +280,7 @@ async function sessionMiddleware(req: NextRequest) {
// 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底
}
return withPathname(req);
return withPathname(req, undefined, styleRewrite);
}
export const config = {
@@ -219,6 +308,7 @@ type SiteMaintState = {
retry_after: number;
};
bypass?: boolean;
url_style?: string;
};
// 维护态短缓存:Next 每次页面/RSC/预取都会进 middleware,开发态尤其密。