nginx 代理路径处理:/api/* vs /api/docs 的两条 location 规则
nginx 代理路径处理:/api/* vs /api/docs 的两条 location 规则
运维同学接了一个投诉:"我们前端能正常打开 /api/auth/login 的页面,Swagger 也打得开,但调用业务接口一直返回 404。日志显示请求到了 127.0.0.1:4001/api/auth/login——NestJS 这边根本没这条路由。"
这不是 NestJS 的 bug,也不是前端代码写错了——是 nginx 反向代理时路径前缀没补完整。前端调用 /api/auth/login,但 NestJS 真实路由是 /api/v1/auth/login,中间差一段 /v1。
这类问题的难点在于:Swagger 是个例外。Swagger 文档挂在 /api/docs 和 /api/docs-json 上,NestJS 里没有 v1 前缀。如果硬补 /v1,Swagger 也 404;如果全部不补,业务接口全部 404。两条 location 规则必须各写各的,且顺序不能乱。
下面把这条链路的每一环拆开讲清楚:尾斜杠在 nginx 里的语义、4 条 location 的优先级、为什么用 127.0.0.1 而不是公网 IP、以及 3 个最容易踩的坑。
一、为什么前端 /api/* 和 NestJS /api/v1/* 长度不一样
这是新手最容易困惑的一步。NestJS 启动时第一行:
// apps/api/src/main.ts:25
app.setGlobalPrefix("api/v1");
这一行决定了所有 Controller 自动挂在 /api/v1 下。所以 AuthController.login() 的代码路径是 /api/v1/auth/login,而不是 /api/v1/api/auth/login——api 已经被全局前缀吃掉了。
但前端代码里调用时写的是 /api/auth/login——这是有意的设计:
| 维度 | 前端调用 | NestJS 实际 |
|---|---|---|
| 业务 API | /api/auth/login | /api/v1/auth/login |
| Swagger 文档 | /api/docs | /api/docs(不带 v1) |
| AppKey 网关 | /api/gateway/xxx | /api/gateway/xxx(不带 v1) |
| 上传文件 | /api/v1/uploads/xxx | /api/v1/uploads/xxx |
后两行是「不被全局前缀影响」的特殊路由:Swagger 是 NestJS 在 setGlobalPrefix 之后显式 SwaggerModule.setup("api/docs", ...) 注册的,AppKey 网关是直接挂到 Fastify 实例上的(不走 NestJS 路由系统),静态上传目录也是直接 fastifyStatic.register({ prefix: "/api/v1/uploads/" })。
也就是说:前端代码不直接写 /api/v1,是为了让前端不感知后端的版本号。未来如果从 /v1 升到 /v2,前端只需要改 nginx 配置,不用动一行 JS。这是经典的「接口版本号归 nginx 管,业务代码不感知」设计。
但代价就是:nginx 必须负责把 /api/* 翻译成 /api/v1/*。这一步出错,前面所有版本治理的设计全部白搭。
二、尾斜杠的语义:nginx 唯一会"踩人"的字符串
proxy_pass 后面接的 URL 是有讲究的——带不带尾斜杠,行为完全不同。这是 nginx 反向代理里最容易踩坑的字符串规则:
# 写法 A:proxy_pass 后面带路径 + 尾斜杠
location /api/ {
proxy_pass http://recon_api/api/v1/; # 注意尾斜杠
}
带尾斜杠时,nginx 会用 location 匹配掉的部分替换 proxy_pass 里的对应部分。
请求 /api/auth/login:
- location
/api/命中,匹配掉前缀/api/ - 剩余路径:
auth/login - proxy_pass 里的
/api/v1/是「替换基底」+「剩余路径」=/api/v1/auth/login - 上游收到:
http://127.0.0.1:4001/api/v1/auth/login
不带尾斜杠(比如 proxy_pass http://recon_api/api/v1;):
- location
/api/命中 - 剩余路径:
auth/login - proxy_pass 里没尾斜杠 → 直接把 location 匹配的原 URI 拼接到 proxy_pass 后
- 上游收到:
http://127.0.0.1:4001/api/v1/auth/login
这两种写法对当前路径结果一致。但如果上游路由写成 /api/v1/users(不带尾斜杠)而请求是 /api/users,不带尾斜杠 会把 /api/v1 当成「目录」——上游收到的路径是 /api/v1api/users,直接 404。
关键结论:
proxy_pass http://recon_api/api/v1/(带尾斜杠)= 把/api/替换成/api/v1/,剩余路径透传。这是 99% 场景下唯一正确的写法。proxy_pass http://recon_api(不带任何路径)= 把原 URI 整段透传,连/api/都不动。Swagger 和 AppKey 网关必须用这种写法。proxy_pass http://recon_api/api/v1(不带尾斜杠但带路径)= 几乎一定是 bug。除非你想把 URI 拼到上游某个固定路径下。
下面这张部署架构图展示了整条链路的物理拓扑——客户端经 nginx 反代到 3001/4001,后端挂 PG17 与 redis:

图 1:整条链路从公网到 NestJS 中间隔了 nginx 这一跳。这一跳决定了
/api/*怎么变成/api/v1/*——尾斜杠写错一个字符,Swagger 与业务接口必有一个 404。
三、4 条 location 的优先级:谁先谁后
完整的 nginx 配置里 /api 相关有 4 条 location,顺序敏感——nginx 按出现顺序匹配,命中即停止:
# ───── Swagger 文档 ─────
location /api/docs { # ① 优先匹配 Swagger UI 路径
proxy_pass http://recon_api/api/docs; # 不带尾斜杠,原 URI 透传
}
location /api/docs-json { # ② Swagger OpenAPI JSON
proxy_pass http://recon_api/api/docs-json;
}
# ───── AppKey 网关 ─────
location /api/gateway/ { # ③ AppKey 触发的第三方回调
proxy_pass http://recon_api; # 不带任何路径,原 URI 透传
}
# ───── 业务 API ─────
location /api/ { # ④ 兜底:所有其他 /api/* 请求
proxy_pass http://recon_api/api/v1/; # 带尾斜杠,补 /v1/ 前缀
}
匹配顺序的详细逻辑:
| 请求路径 | 命中 location | proxy_pass | 上游收到的完整路径 |
|---|---|---|---|
/api/docs | ① /api/docs | http://recon_api/api/docs | /api/docs |
/api/docs-json | ② /api/docs-json | http://recon_api/api/docs-json | /api/docs-json |
/api/gateway/kingdee/push | ③ /api/gateway/ | http://recon_api | /api/gateway/kingdee/push |
/api/auth/login | ④ /api/ | http://recon_api/api/v1/ | /api/v1/auth/login |
/api/bill-import/upload | ④ /api/ | http://recon_api/api/v1/ | /api/v1/bill-import/upload |
关键的设计决策:
-
①②③ 必须排在 ④ 前面。
location /api/是前缀匹配,能匹配所有/api/开头的请求——包括/api/docs和/api/gateway/。如果 ④ 排第一,所有请求都会被它吞掉,Swagger 和网关全部被错配成/api/v1/docs这种路径,404。 -
① / ② 用「精确前缀」(无尾斜杠)而不是「目录前缀」。
location /api/docs(无尾斜杠)只匹配/api/docs自身,不会匹配/api/docs-anything。这种写法让 Swagger 路径变更(如升级到/api/docs/v2)不会被误吞。 -
③ 必须用
proxy_pass http://recon_api(不带任何路径)。/api/gateway/kingdee/push这种长路径如果走尾斜杠的proxy_pass http://recon_api/api/gateway/,会和 NestJS 真实路由/api/gateway/*不一致——上游会收到/api/gateway/api/gateway/kingdee/push,多了一段。 -
④ 用
proxy_pass http://recon_api/api/v1/是唯一正确写法。业务接口全部要补/v1/前缀,尾斜杠是必须的。
四、为什么必须转发到 127.0.0.1,不能直接暴露公网 IP
配置里有两段看似无关但强相关的设计:
upstream recon_api {
server 127.0.0.1:4001; # ← 这里
keepalive 16;
}
server {
listen 3002;
server_name 47.120.46.178 _;
# ...
}
47.120.46.178 是公网 IP,127.0.0.1:4001 是本机回环地址。这两者必须不直接对外连通——所有外部请求都强制走 nginx,再由 nginx 转给本机的 NestJS。
这是因为 NestJS 内部有个网关旁路 JWT 的设计:AppKey 网关在验证签名后,会注入一个 x-gateway-user-id HTTP 头,标识「这个请求是哪个 appKey 用户发起的」。NestJS 收到这个头后会跳过 JWT 校验,直接信任为「已认证用户」。看 gateway.register.ts:131:
headers["x-gateway-user-id"] = record.userId;
正常链路下这是安全的——只有内网 Fastify 实例才能注入这个头,外部客户端伪造不了。但如果 4001 端口直接暴露在公网,攻击者只需要发一个 x-gateway-user-id: 任意用户ID 的请求,就能绕过所有 JWT 验证,以任意用户身份调用任何 API。
这就是「反向代理不只是为了负载均衡,更是为了隔离敏感端口」的工程含义。
强制要求:
| 端口 | 监听地址 | 是否暴露公网 | 原因 |
|---|---|---|---|
| 3001(Next.js) | 127.0.0.1 | ❌ 否 | 上传文件、SSE 流式响应、内部 API |
| 4001(NestJS) | 127.0.0.1 | ❌ 否 | x-gateway-user-id 旁路 JWT 风险 |
| 190(nginx) | 0.0.0.0 | ✅ 是 | 唯一对外入口,统一 TLS 终止 |
防火墙层面也要做硬隔离:服务器本机的 iptables/firewalld 应该拒绝外部对 3001/4001 的访问,仅允许 190/80/443。下面这张 API 网关限流熔断架构图从更高维度展示了这条链路的安全约束:

图 2:API 网关的核心职责是统一入口 + 隔离后端。TLS 终止在 nginx,敏感端口全部锁在本机回环地址,外部只能通过 190/80/443 进来。这是
x-gateway-user-id头不被伪造的物理前提——也是轻易云智能对账系统在 v3 重构后反复强调的「版本号归 nginx 管、敏感头归内网管」双轨设计的工程含义。
如果想把这套架构落到自己的对账系统,关键不是「复制 nginx 配置」,而是把以下三件事变成硬约束:
- 监听地址分离:业务端口(3001/4001)强制
127.0.0.1,nginx 强制0.0.0.0 - 防火墙隔离:本机 iptables 拒绝外部访问 3001/4001,只放过 nginx
- 路径前缀分层:
/api/v1/*(业务)、/api/docs*(Swagger)、/api/gateway/*(AppKey)三条独立 location,按优先级排列
这套设计的最终形态,就是把「对外只暴露一个端口」变成可观察的运维契约:SRE 不需要查代码就知道哪些端口可以暴露、哪些不行。
五、3 个最容易踩的坑
坑 1:location 顺序写反,所有 Swagger 变成 404
# ❌ 错误写法
location /api/ {
proxy_pass http://recon_api/api/v1/;
}
location /api/docs {
proxy_pass http://recon_api/api/docs;
}
症状:/api/docs 打不开,浏览器报 404。但 /api/auth/login 正常。
根因:/api/ 是前缀匹配,会匹配 /api/docs。nginx 按出现顺序匹配,第一条就命中了,/api/docs 被翻译成 /api/v1/docs——NestJS 没有这个路由。
修复:把 Swagger 两条 location 排到 /api/ 之前。
坑 2:proxy_pass 没带尾斜杠,业务接口 404 但 Swagger 正常
# ❌ 错误写法
location /api/ {
proxy_pass http://recon_api/api/v1; # 缺尾斜杠
}
症状:Swagger 打 /api/docs 正常,业务接口 /api/auth/login 报 404。
根因:缺尾斜杠 → nginx 把 location 匹配掉的 /api/ 部分保留拼回去。上游收到的是 /api/v1api/auth/login——v1 和 api 中间没分隔符,NestJS 没这条路由。
修复:加尾斜杠:proxy_pass http://recon_api/api/v1/。
坑 3:/api/gateway/ 也被错配成 /api/v1/gateway/*
# ❌ 错误写法
location /api/gateway/ {
proxy_pass http://recon_api/api/gateway/; # 多余的路径
}
症状:金蝶云星空 PUSH 回调 /api/gateway/kingdee/push 报 404。
根因:这里带路径是多余的。NestJS 真实路由就是 /api/gateway/*,nginx 不该再补一段。多写一段后上游收到的路径是 /api/gateway/api/gateway/kingdee/push——api/gateway/ 出现了两次。
修复:proxy_pass http://recon_api;(不带任何路径),让原 URI 完整透传。
坑 4(隐藏):4001 端口被云厂商安全组暴露
# ❌ 部署脚本里漏掉这一段
# 期望:仅 190/80/443 在公网 listen
# 实际:有些云厂商(轻量应用服务器、SAE)的默认安全组会把所有端口暴露
症状:上面 3 条 location 全部正确,但日志显示请求不是从 nginx 来的——是直连 4001 进来的。
根因:云厂商默认安全组开放了所有端口,攻击者绕过 nginx 直接访问 NestJS。一旦绕过,伪造 x-gateway-user-id 头就能冒充任意用户。
修复:在云厂商控制台显式关闭 3001/4001 的入站规则,只开放 nginx 监听的端口(190/80/443)。本地再用 iptables 兜底:
# 拒绝外部访问 4001(只允许本机)
iptables -A INPUT -p tcp --dport 4001 -s 127.0.0.1 -j ACCEPT
iptables -A INPUT -p tcp --dport 4001 -j DROP
下面这张集成转换流程图揭示了一个相关规律——所有跨边界的数据流转都有「路径对齐 + 权限隔离」两道闸,部署流水线、知识库同步、nginx 代理都是同一套工程哲学:

图 3:集成转换是另一个「跨边界数据流转」场景——转换脚本生成单据头+体行、推送 ERP、PUSH 失败重试。和 nginx 代理是同一套工程哲学:每一步都有明确的「路径对齐 + 权限隔离」两道闸,错位任一闸都会导致数据丢失或安全漏洞。工程的成熟度不是「能不能跑」,而是「错位的时候能不能挡住」。
六、运维 take-away:3 个不能省的检查项
把这套 nginx 代理的工程约束沉淀成运维侧的 3 条硬纪律:
| 检查项 | 命令 / 操作 | 期望 |
|---|---|---|
| 监听地址 | ss -tlnp | grep -E '3001|4001|190' | 3001/4001 必须是 127.0.0.1,190 是 0.0.0.0 |
| location 顺序 | grep -n 'location /api' nginx.conf | Swagger 两条必须排在 /api/ 之前 |
| 尾斜杠 | grep 'proxy_pass.*recon_api' nginx.conf | /api/ 后面必须带 /v1/ 路径 + 尾斜杠;Swagger 与 Gateway 必须不带任何路径 |
| 公网暴露 | 云厂商安全组 + iptables -L INPUT -n | 3001/4001 仅 127.0.0.1 可达,公网全 DROP |
最后一条尤其关键:监听地址分离是物理前提。如果哪天有人图省事把 NestJS 改成 0.0.0.0 监听(某些调试工具默认这么干),前面所有 location 规则都失效——攻击者会绕开 nginx 直接伪造头。
把这一切放回工程语境:nginx 反代不是「装一下就能用」的运维配置,而是「前端版本号、后端版本号、安全敏感头、跨边界路径」四条线的交汇点。每一条线都有自己的约束,错位任一约束都会让线上服务出现 404、502、或者更糟——被伪造用户身份。
回到开头的故障。运维同学按本文档检查了 3 件事:监听地址、location 顺序、尾斜杠——结果发现是尾斜杠漏了,proxy_pass http://recon_api/api/v1; 少了斜杠。加上斜杠后,404 消失,业务接口立即恢复。整个故障定位用了 8 分钟,修复用了 30 秒。
如果你是第一次部署这类 nestjs + next.js 的双进程 monorepo,把这三条 location 规则、监听地址分离、x-gateway-user-id 的物理隔离写成一份「部署即合规」checklist——比临时排查有效得多。运维这种「敏感头归内网、版本号归 nginx」的双轨分层设计,也是轻易云智能对账系统在 v3 重构后反复打磨出来的工程哲学——运维不需要查代码就知道哪些端口可以暴露、哪些不行。
更深度的后端 API 设计模式与业务合规审计,可参见项目内的后端模式指南与业务合规审计 skill(路径见 .opencode/skills/backend-patterns/SKILL.md 与 .opencode/skills/product-manager-review/SKILL.md)——它们对「接口版本治理 + 权限分层」做了更完整的展开。
两条 location 规则、四个落点、一个 127.0.0.1——nginx 代理的工程价值不在「装上就能用」,而在「错位的时候能不能挡住」。
[来源:仓库根 scripts/nginx.conf 的 4 条 location 规则、scripts/install-nginx.sh 的 4 种部署模式、apps/api/src/main.ts:25 的 setGlobalPrefix 定义、apps/api/src/gateway/gateway.register.ts:131 的 x-gateway-user-id 注入逻辑]
[来源:2026-09-24 运维故障复盘,proxy_pass 缺尾斜杠导致业务接口全 404 的 8 分钟定位记录]