你的 404 是个路由 bug,而它藏在索引页里
多语言路由会在一个特定的地方坏掉:没有 slug 的那一页。它下面的所有页面都正常 —— 这正是没人发现它的原因。

你的 404 是个路由 bug 🚧
一个我们已经在两个不同代码库里踩到两次、根因完全相同的模式:
每一篇文章页都正常。每一篇文档页都正常。那个板块的索引页 404,而且只在非默认语言下 404。
它能活过评审,是因为评审的人点链接。链接指向文章。没人会去手敲那个光秃秃的板块 URL,所以在真实用户撞上之前没人看得见 —— 或者直到有人把板块落地页分享出去,对方打开是个 404。
为什么偏偏是索引页
多语言内容路由通常长这样:/[lang]/docs/[[...slug]],而内容文件按一个已经编码了语言的路径来索引:docs/en/getting-started、docs/zh/getting-started。
查一篇文章是没有歧义的 —— 你有语言、有 slug,拼起来,找到文件。
索引页是它崩掉的地方,因为索引页没有 slug,而那段计算"这份文档的路径"的代码,必须为一份只有语言的文档产出点什么。于是两件事撞在一起:
- 在默认语言下,框架通常会把语言前缀整个省掉,所以索引页算出来的路径是空字符串。
- 在其他所有语言下,索引页算出来的路径就是语言代码本身 ——
zh,不是zh/,也不是空。
只按其中一种情况写查找,另一种就 404。只按文章的情况写,两种都 404。bug 不在路由里,它在那个"形状和其他所有输入都不一样"的输入值里。
修法的形状:
// slug 为空:我们在板块索引页上。
if (!slugPath) {
// 非默认语言:索引文档的路径**就是**语言代码。
if (lang !== defaultLocale) {
return docs.find((d) => d.published && d.slugAsParams === lang) ?? null;
}
// 默认语言:前缀被剥掉了,所以索引路径是 ""。
return docs.find((d) => d.published && d.slugAsParams === "") ?? null;
}七行。难的从来不是写这七行。
它所属的那一类
这是一个更宽的东西的实例,值得起个名字,因为它在路由之外反复出现:
空的那种情况和一般情况形状不同,而一般情况是先写的。
某一段为空时的路径拼接。第一页没有游标的分页。"没有筛选"不是一个空的筛选对象而是压根不存在的筛选列表。长度为零的面包屑。每一个里面,代码处理了 N 而悄悄处理错了 0 —— 而 0 在测试里罕见到不会被发现,在生产里常见到会出事。
怎么真正抓住它
三件事,由便宜到贵。
把路由枚举出来,挨个请求。 如果你的内容管线能列出文档,它就能生成 URL 列表,而在这个列表上循环查状态码就是一个冒烟测试 —— 它能抓住我们那两次里的每一次。关键在于必须包含板块根路径,而不只是文档 —— 这意味着要专门生成它们,因为它们并不是同一意义上的"文档"。
for url in $(cat routes.txt); do
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE$url")
[ "$code" = "200" ] || echo "FAIL $code $url"
done测边界,不测中间。 对任何列表形状的输入,能找出 bug 的情况是:零个、一个、以及那个形状和邻居都不一样的。区间中段几乎从不。
检查每一种语言,不是抽两种。 一个依赖"默认 vs 非默认"的 bug,查英文加另外一种就能暴露。一个依赖某种语言自身怪癖的 bug —— 带地区子标签的、带书写体变体的、语言代码是另一个的前缀的 —— 只有全查才会暴露。枚举很便宜;抽样才是放它们过关的那件事。
值得内化的那一点
当一个板块里有一页 404 而其余都正常时,别从头到尾通读路由处理函数。先问:那一页的输入有什么不一样。在多语言路由里,答案几乎总是 —— 它的 slug 是空的,或者就是语言代码,而查找逻辑两种都没写。