
做前后端分离项目时最烦的不是写业务逻辑而是本地联调。前端跑在 3000后端接口跑在 8080浏览器随手一个 fetch 就是跨域报错后端同事说“我本地起了个服务你帮我连一下”你又不想改一堆环境变量。后来我干脆写了这个 PHP 单文件代理服务器塞进任意一个 PHP 环境用内置 Web 服务器一跑就能把请求按前缀转发到不同后端顺带解决跨域、记录日志、附加请求头。整个过程就一个文件没有框架依赖没有外部服务PHP 7.4 到 8.3 都能跑。这篇文章会把设计思路、完整代码、启动步骤和调试中踩过的坑一次讲清楚适合做前端联调、后端排接口、需要快速搭建临时转发网关的同学。我之前在好几家公司见过同类型需求大家第一反应都是“配个 Nginx 反代”或者“上 Node 中间层”。Nginx 当然稳定但为了联调改一堆 server 块改完还要 reload最后还不一定带得走。Node 中间层要装依赖、写中间件也是一个工程。于是我把方案收敛成一个 PHP 文件几行 cURL 转发一台机器有一份 PHP 就能马上跑起来。下面从场景到原理、代码、排坑一层层说清楚。1. 单文件代理服务器到底在解决什么场景问题1.1 本地跨域调试为什么需要中转浏览器同源策略大家都懂前端开发时最容易踩的就是跨域。同一个页面里页面运行在http://127.0.0.1:5173后端接口在http://127.0.0.1:8000两个端口不同浏览器就会拦截响应。虽然后端可以通过 CORS 头放行但有些老项目、内部系统根本没加 CORS 支持你去改后端代码又不现实。代理解决这个问题的方式很直接让浏览器只访问代理这一个源例如http://127.0.0.1:8080由代理服务器去请求真实的多个后端地址再把结果带回来。因为代理方向后端的请求是服务端发起的不涉及浏览器同源策略代理作为返回源只要它在响应头里附上Access-Control-Allow-Origin: *前端收到的请求就完全没有跨域问题。这个场景是最刚需的。我见过很多前端同事为了跨域在 vite 里配 proxy在 webpack 里配 proxy项目一多配置就散落到各个仓库里。单文件代理是一个脱离项目的独立工具任何项目需要联调时都能临时指过去用完就关不污染项目配置。1.2 多路由转发一个端口接入多个后端服务很多联调场景不止一个后端。我这边最常见的情况是页面、API 网关、SSO 登录三个服务同时开着前端希望用自己的域名访问又不希望每次登录跳转都改域名。单文件代理可以用前缀规则做分流/api/开头的请求转发到 API 服务http://127.0.0.1:3000/static/开头的请求转发到静态资源服务http://127.0.0.1:8081/sso/开头的请求转发到统一登录服务http://127.0.0.1:9000前端代码里只需要写一个基础地址http://127.0.0.1:8080后面接路径即可。后端服务换端口了改一下代理文件里的路由表刷新页面就生效比逐个改前端配置要省事得多。在一个文件里维护路由表还有一个隐性好处这份配置可以跟着你走。同一份 proxy.php 拷贝到另一台电脑配置不用重新写非常适合同事之间互相对接。1.3 和其他反代方案比单文件PHP的取舍我对比过几种常见方案各有适用场景。Nginx 反代最稳定适合正式环境或长期驻留的本地网关但配置较重临时起一个还需要写 conf 文件、改端口。Node 的http-proxy中间件灵活但要初始化 npm 项目、装依赖。Python 写转发脚本也不长但同样的脚本在不同机器上要处理虚拟环境。PHP 单文件代理的优势主要在“轻”和“到处能跑”。只要机器上有 PHP CLI 环境一条php -S命令就启动不需要额外的服务进程不需要装依赖。很多公司的开发机即使没装 Node也大概率有 PHP因为历史项目里总有 PHP 的影子。限制也很明显它是同步阻塞的一个请求没结束同一个 worker 不会处理下一个并发请求。当然 PHP 内置服务器本身是单进程模型所以它更适合“联调、排接口、临时网关”这种低并发场景不适合承担生产流量。它的定位就是开发工具箱里的一把螺丝刀不是重型扳手。下面是几个方案的粗略对比方案依赖启动成本路由灵活性日志能力适用场景Nginx 反代Nginx改配置reload强强正式/长期网关Node http-proxynpm 依赖初始化项目强可写工程化中间层Python 转发脚本Python需解释器中可写临时脚本PHP 单文件PHP curl 扩展一条命令中内置日志快速联调/临时转发我在实际项目里两者都用正式环境走 Nginx本地开发调试基本都用单文件代理。切换成本低唯一要记住的是别把它当生产工具用。2. 核心实现原理与关键细节拆解2.1 一次请求从进入到返回的完整链路整个代理的处理流程并不复杂我用一句话先概括收到请求、拼目标 URL、带原请求头和后端通信、把后端响应原样回给客户端。拆开看有六个环节解析当前请求的 URL 和 HTTP 方法拿到$_SERVER[REQUEST_URI]与$_SERVER[REQUEST_METHOD]。根据路由表做前缀匹配确定本次要转发的目标地址拼接出完整的转发 URL。把当前请求的请求头转成 cURL 能用的 header 数组并补上X-Forwarded-For这类代理标记。读取请求体php://input按方法判断是否需要作为 POSTFIELDS 传给后端。用 cURL 执行转发拿到后端返回的状态码、Content-Type 和响应 body。用http_response_code()回写状态码透传 Content-Type输出 body再补上 CORS 头最后写日志。这里最容易被忽略的是请求头处理。浏览器发给代理的 Header 里有Host、Connection、Accept-Encoding等这些如果原封不动转发给后端轻则语义不对重则把连接管理的头也带过去导致后端响应异常。我们后面单独讲这一点。2.2 转发引擎选型cURL为什么比 stream context 更顺手实现 HTTP 转发在 PHP 里有两条路cURL 扩展或者file_get_contents()stream_context_create()。两条路都能做但我强烈建议用 cURL因为要拿的信息太全了。cURL 里有一个curl_getinfo()方法转发结束后能拿到 HTTP 状态码、Content-Type、重定向次数、总耗时、上传/下载字节数甚至远程 IP。而file_get_contents()虽然可以通过$http_response_header拿到响应头但要解析状态码、Content-Type、Set-Cookie 这些字段全得自己手写正则或者字符串截取。再看请求方法的支持。file_get_contents()配合 stream context 虽然也能设置method为 POST、PUT、PATCH但对于 DELETE、OPTIONS 这类方法的兼容性不如 cURL 直接。cURL 一行CURLOPT_CUSTOMREQUEST就能发任意方法。超时控制也一样cURL 有专门的CURLOPT_TIMEOUTstream context 需要设置timeout和http-ignore_errors等参数细节更多。还有一个实用功能cURL 对 HTTPS 的控制非常灵活。本地调试经常遇到自签名证书cURL 可以临时关闭证书校验stream context 虽然也能配置但参数比较绕。总结下来cURL 天生就是为发送 HTTP 请求设计的单文件代理里选它是最省心的。唯一需要注意的是 curl 扩展必须有好在这几乎是 PHP 的标配扩展php -m里看到curl就能用。2.3 请求头与响应头的处理细节请求头处理是代理服务器里最容易出问题的地方这里列几个关键细节。第一个是 Host 头。浏览器发给代理的 Host 是127.0.0.1:8080如果你把这个 Host 原样转发给后端后端虚拟主机可能就找不到对应站点或者日志里记录的全是代理地址。默认做法是去掉原始 Host 头让 cURL 使用目标 URL 自带的 Host。比如目标地址是http://127.0.0.1:3000cURL 发出去时 Host 就是127.0.0.1:3000后端看到的就是请求真实指向的地址符合直觉。但有一种情况需要保留原始 Host后端在同一个 IP 上用 Nginx 按域名做虚拟主机分发比如http://127.0.0.1:80上有dev-api.example.com和dev-admin.example.com两个站点此时代理的目标地址写成 IP实际要访问哪个站就得靠 Host 决定。这时把preserve_host打开把浏览器的 Host 原样透传过去后端虚拟主机才能正确路由。这段逻辑在代码里做成了一个配置开关默认关闭需要时打开。第二个是 hop-by-hop 头。HTTP 协议里有一组头是连接级别的比如Connection、Keep-Alive、Transfer-Encoding、TE、Trailer、Upgrade、Proxy-Connection它们描述的是当前这一段连接的信息不应该由代理转给下一个节点。我把它们过滤掉让 cURL 自己管理连接层后端收到的头更干净。第三个是X-Forwarded-For。代理转发时最好补上这个头后端拿到后可以知道最原始的真实客户端 IP。这个头本身是追加式的如果后端链路上已经有多层代理通常的做法是把新 IP 追加到末尾而不是直接覆盖。单文件代理里我直接取了REMOTE_ADDR逻辑简单足够开发场景使用。第四个是Accept-Encoding。我在调试中踩过坑如果前端带了Accept-Encoding: gzip后端返回的 body 是压缩后的二进制你在代理日志里看到的就是一坨乱码想要基于 body 做点分析和统计都没法做。单文件代理里我把这个请求头过滤掉让后端返回默认的未压缩内容调试时一眼就能看清数据。生产环境不能这么省事开发工具可以。响应头的处理相对简单主要是透传Content-Type以及状态码回写。如果你增加 CORS 头就自己补上那几行。注意不要重复输出同名的响应头cURL 拿到的Content-Type如果为空就不要强制设置。2.4 安全边界把代理锁在本地开发环境里单文件代理本质是一个请求转发器它天然具备“开放代理”的能力只要谁能访问它它就能替谁去访问配好的目标地址。所以如果不做限制一旦这个文件被暴露到公网就变成了一个别人可以利用的跳板。我在代码里加了两层防护。第一层是allowed_clients白名单只允许特定来源 IP 访问代理。默认只允许本机127.0.0.1这样部署在开发机时只有本机能使用。如果你需要局域网内的同事访问可以把自己的局域网 IP 加进去但不要图省事直接写*。第二层是allowed_targets白名单代理只能转发到名单里的目标主机。即使有人偷偷拼接路径只要目标主机不在白名单里就直接返回 403。这个设计很简单但对于避免代理被滥用很有价值。我把目标地址安全校验放在路由匹配之后避免单纯依赖路由前缀做防护。需要明确的是这套东西只是开发工具不应该被部署成面向公网的正式服务。我给它的定位是“本地联调助手”而不是“互联网网关”。在这种定位下即使安全措施做得还不算深度风险也是可控的。3. 完整可运行的PHP代码与启动步骤3.1 完整代码下面是完整的单文件代理服务器代码。代码里把配置项集中在最上方方便修改核心逻辑在路由匹配、请求头组装、cURL 转发和日志输出上。?php /** * proxy.php * 单文件 HTTP 转发代理面向本地开发调试场景 * * 用法 * php -S 127.0.0.1:8080 proxy.php * * 配置项在下方 $config 数组里修改后重启即可。 */ // ---------- 配置区 ---------- $config [ // 默认转发目标没有匹配到任何路由前缀时使用 default_target http://127.0.0.1:3000, // 按 URL 前缀转发到不同后端 routes [ /api/ http://127.0.0.1:3000, /static/ http://127.0.0.1:8081, /sso/ http://127.0.0.1:9000, ], // 目标主机白名单防止代理变成开放代理 allowed_targets [ 127.0.0.1, localhost, ], // 允许访问代理的客户端来源留空表示不限制 allowed_clients [ 127.0.0.1, ], // 转发超时时间单位秒 timeout 30, // 日志文件路径留空表示不写日志 log_file __DIR__ . /proxy.log, // 是否保留原始 Host 头按需开启 preserve_host false, // 是否自动附加 CORS 头解决前端跨域调试 add_cors_headers true, ]; // ---------- 兼容非 Apache 环境的 getallheaders ---------- if (!function_exists(getallheaders)) { function getallheaders(): array { $headers []; foreach ($_SERVER as $key $value) { if (strpos($key, HTTP_) 0) { $name str_replace(_, , strtolower(substr($key, 5))); $name str_replace( , -, ucwords($name)); $headers[$name] $value; } } if (isset($_SERVER[CONTENT_TYPE])) { $headers[Content-Type] $_SERVER[CONTENT_TYPE]; } if (isset($_SERVER[CONTENT_LENGTH])) { $headers[Content-Length] $_SERVER[CONTENT_LENGTH]; } return $headers; } } // ---------- 客户端来源检查 ---------- if (!empty($config[allowed_clients])) { $clientIp $_SERVER[REMOTE_ADDR] ?? ; if (!in_array($clientIp, $config[allowed_clients], true)) { http_response_code(403); header(Content-Type: application/json; charsetutf-8); echo json_encode([error client not allowed]); exit; } } // ---------- 解析请求 ---------- $requestUri $_SERVER[REQUEST_URI] ?? /; $requestMethod strtoupper($_SERVER[REQUEST_METHOD] ?? GET); // 去掉协议和域名干扰只保留路径和查询参数 $requestPath parse_url($requestUri, PHP_URL_PATH) ?: /; $requestQuery parse_url($requestUri, PHP_URL_QUERY); $normalizedUri $requestPath . ($requestQuery ? ? . $requestQuery : ); // ---------- 路由匹配 ---------- $target $config[default_target]; foreach ($config[routes] as $prefix $baseUrl) { if ($prefix ! strpos($normalizedUri, $prefix) 0) { $target rtrim($baseUrl, /); break; } } $remoteUrl $target . $normalizedUri; // ---------- 目标地址安全校验 ---------- $targetHost parse_url($remoteUrl, PHP_URL_HOST); if (!in_array($targetHost, $config[allowed_targets], true)) { http_response_code(403); header(Content-Type: application/json; charsetutf-8); echo json_encode([error target host not allowed]); exit; } // ---------- 组装转发请求头 ---------- $forwardHeaders []; foreach (getallheaders() as $name $value) { $lowerName strtolower($name); if (in_array($lowerName, [ host, connection, keep-alive, transfer-encoding, te, trailer, upgrade, proxy-connection, accept-encoding, ], true)) { continue; } $forwardHeaders[] $name . : . $value; } if (!empty($config[preserve_host]) isset($_SERVER[HTTP_HOST])) { $forwardHeaders[] Host: . $_SERVER[HTTP_HOST]; } $forwardHeaders[] X-Forwarded-For: . ($_SERVER[REMOTE_ADDR] ?? ); $forwardHeaders[] X-Forwarded-Proto: . (isset($_SERVER[HTTPS]) $_SERVER[HTTPS] ! off ? https : http); // ---------- 读取请求体 ---------- $requestBody file_get_contents(php://input); // ---------- 执行转发 ---------- $startTime microtime(true); $ch curl_init($remoteUrl); if ($requestMethod GET) { curl_setopt($ch, CURLOPT_HTTPGET, true); } elseif ($requestMethod POST) { curl_setopt($ch, CURLOPT_POST, true); if ($requestBody ! ) { curl_setopt($ch, CURLOPT_POSTFIELDS, $requestBody); } } else { curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $requestMethod); if ($requestBody ! ) { curl_setopt($ch, CURLOPT_POSTFIELDS, $requestBody); } } curl_setopt($ch, CURLOPT_HTTPHEADER, $forwardHeaders); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_MAXREDIRS, 5); curl_setopt($ch, CURLOPT_TIMEOUT, $config[timeout]); // 本地开发调试时关闭证书校验生产环境绝不能照抄 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); $responseBody curl_exec($ch); $statusCode (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $contentType curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $curlError curl_error($ch); $elapsedMs round((microtime(true) - $startTime) * 1000, 1); curl_close($ch); // ---------- 回写响应 ---------- http_response_code($statusCode 0 ? $statusCode : 502); if ($contentType) { header(Content-Type: . $contentType); } if (!empty($config[add_cors_headers])) { header(Access-Control-Allow-Origin: *); header(Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With); } echo $responseBody; // ---------- 写日志 ---------- if (!empty($config[log_file])) { $logLine sprintf( [%s] %s %s - %d %dB %dms target%s%s\n, date(Y-m-d H:i:s), $requestMethod, $normalizedUri, $statusCode, strlen((string) $responseBody), $elapsedMs, $targetHost, $curlError ? [ . $curlError . ] : ); file_put_contents($config[log_file], $logLine, FILE_APPEND); }这段代码我实际用过配合自定义路由和 CORS 头能覆盖绝大多数本地联调场景。要注意的是代码默认假设请求体不需要流式读取因此file_get_contents(php://input)对于普通 JSON 接口完全够用大文件上传场景后面会讲替代方案。3.2 环境准备和启动命令运行这份代码的前提非常简单一台装有 PHP 的机器PHP 7.4 以上即可8.0、8.1、8.3 我都验证过。PHP 内置开发服务器和 cURL 扩展属于标配一般不需要额外装东西。可以先跑两条命令确认环境php -v php -m | grep curl如果输出里能看到curl环境就绪。把上面的代码保存为proxy.php然后在终端里执行php -S 127.0.0.1:8080 proxy.php这里php -S是 PHP 内置开发服务器127.0.0.1:8080表示监听本机 8080 端口proxy.php是路由脚本。在 PHP 内置服务器模式下所有请求都会交给这个路由文件处理所以它天然成为整个代理的入口。启动后终端会打印类似这样的信息Listening on http://127.0.0.1:8080 Document root is /path/to/current/directory Press Ctrl-C to quit.这时代理已经跑起来了。前端代码里的baseURL直接指到http://127.0.0.1:8080后面正常请求/api/users之类的路径即可。如果你用的是 Windows也一样php -S命令通用只不过终端窗口不要关。还可以往命令后面加-t指定网站根目录但单文件代理不需要。3.3 关键配置项逐条说明配置区里最常改的是路由表。routes数组的匹配规则是拿当前请求路径的前缀去和路由 key 比较谁先命中谁先赢。例如路由/api/会匹配/api/users、/api/order/123但不会匹配/apiv2/test。如果你的接口路径不是明确前缀需要调整前缀规则。allowed_targets这个白名单要特别注意。我把默认值设置成127.0.0.1和localhost意思是代理只能转发到本机的端口。同事把他的电脑 IP 告诉你你想转发到他的机器需要把他的 IP 加进这个数组同时确认你们在同一局域网。不要把目标地址写成域名但忘了把域名加白名单否则请求会被 403 拦下来排查时会有一点懵。preserve_host前面说过是给“同 IP 不同虚拟主机”场景用的。本地联调一般用不上保持false就行。如果你代理的目标地址是一个域名而不是 IP保留不保留 Host 差异不大cURL 本身会以目标域名为 Host。add_cors_headers在前端跨域调试时建议保持true。它会让代理替所有响应附加Access-Control-Allow-Origin: *前端页面就能直接拿到数据。如果你要带 Cookie 跨域就不能用*而是要把这行改为具体的前端源域名并开启Access-Control-Allow-Credentials下面排坑部分会展开。timeout默认 30 秒。如果你在联调一个慢查询接口30 秒不够就把它调大。日志字段里的耗时也会实时变化方便你判断瓶颈在哪台机器。3.4 用 curl 实测验证启动后先用命令行验证一下转发是否正常。最简单的是 GETcurl -i http://127.0.0.1:8080/api/users-i会打印响应头。如果代理正常你会看到 200 状态码、透传回来的Content-Type然后是后端返回的 body。如果后端没起代理会拿到一个连接失败错误CURLINFO_RESPONSE_CODE为 0最后回写 502。再测一个 POST JSONcurl -X POST http://127.0.0.1:8080/api/user \ -H Content-Type: application/json \ -d {name:dev,age:18}这时看代理日志应该有一行记录该请求和耗时。日志里能看到转发的目标主机方便确认路由有没有命中。如果想确认 CORS 头是否生效上面的命令加-H Origin: http://127.0.0.1:5173再-i看响应头里是否出现Access-Control-Allow-Origin: *。浏览器跨域调试时这个头是关键。4. 实战里的坑与排查技巧4.1 重定向和302状态被吞的问题我在做登录流程联调的时候踩过一个大坑后端登录接口返回 302期望浏览器自己跳转到首页。结果用了代理之后前端拿到的不是 302而是跳转后的首页 HTML。原因是代码里开了CURLOPT_FOLLOWLOCATION让 cURL 自动跟随了重定向。自动跟随重定向在日常接口转发时很方便少处理一跳但如果你需要保留 302 状态给前端或者需要观察每一次跳转的 Location这个选项反而碍事。我的处理策略是默认跟随但把这个行为抽成一个配置项。如果你也写自己的版本可以让follow_redirect在true和false之间切换false时不设FOLLOWLOCATION直接把后端 302 和Location头原样回给前端。另外还要注意跟随重定向时 cURL 默认会把后续响应的内容作为最终结果中间跳转过程不会出现在日志里。如果你怀疑跳转链路过长或有循环可以把MAXREDIRS调小重定向次数太多时会返回错误日志里能看到报错信息。4.2 会话保持Cookie和跨域Credentials的配合本地联调最常见的会话问题是登录成功之后后续请求没有携带 Cookie导致一直 401。首先看代理层代理把浏览器发来的Cookie请求头原样转发给后端后端下发的Set-Cookie也会原样回给浏览器这两个环节默认是通的。问题多半出在浏览器跨域策略上。如果前端域名是http://127.0.0.1:5173代理是http://127.0.0.1:8080前端要携带 Cookie必须在 fetch 里设置credentials: include同时代理响应的 CORS 头不能是Access-Control-Allow-Origin: *必须是具体的源地址并且要加上Access-Control-Allow-Credentials: true。浏览器的规则很严格*和credentials同时出现会直接拒绝请求。我实际使用时是手动把Access-Control-Allow-Origin改成前端地址。你如果经常需要带 Cookie 联调可以在配置区加一个cors_origin字段动态填充这个头而不是写死*。4.3 SSL证书、压缩编码和中文乱码本地开发遇到 HTTPS 后端很常见。比如前端联调一个测试环境的域名证书是公司内部签发的自签名证书cURL 默认校验会报错返回的错误信息通常是SSL certificate problem: self-signed certificate。代码里已经默认关闭了证书校验但我要提醒这个开关只适合本地开发。如果把这份代码部署到正式环境必须把CURLOPT_SSL_VERIFYPEER改回true否则就无法确认通信对端身份安全边界等于没有。再说压缩编码。如果你测试时发现代理日志里的响应体是一片乱码先检查请求头里是否带了Accept-Encoding: gzip。我在代码里过滤了这个请求头后端通常不会返回压缩内容。但有些后端框架会强制开启 Gzip 中间件这时候响应头里会出现Content-Encoding: gzipbody 是二进制。临时处理方式是给 cURL 设置CURLOPT_ENCODING 让 cURL 自动解压然后记得把响应头里的Content-Encoding移除否则浏览器会再做一次解压导致错误。中文乱码一般不是代理的问题更多是 Content-Type 里的 charset 设置没透传好。后端如果返回application/json; charsetutf-8我的代码会把整个Content-Type原样透传浏览器按 UTF-8 解析就不会乱码。如果后端漏了 charset前端可以自己在response.text()之后用TextDecoder兜底代理不需要转码。4.4 日志轮转和大请求体的资源控制日志默认写到一个文件里长年累月会越来越大。一个折中方案是在写日志时把文件名按天分隔开$logFile $config[log_file] . . . date(Y-m-d);这样每天一个文件观察问题的时候按日期去找也方便。不要忘了给日志目录写权限否则file_put_contents失败并只是被静默吞掉你还在奇怪为什么没日志。大请求体的坑更隐蔽。file_get_contents(php://input)会把整个请求体载入 PHP 进程内存。如果只是传几百 KB 的 JSON 完全没事但你要代理一个几十 MB 的文件上传内存占用就可能顶不住。PHP 内置服务器本身是单进程内存一爆炸整个代理就挂了。针对文件上传场景更合理的做法是用 cURL 的CURLOPT_INFILE从文件流读取或者干脆不要用单文件代理直接用 Nginx 反代。这也是为什么我一直强调它只适合开发场景。4.5 常见问题速查表最后把实际使用中碰到的问题整理成一张速查表方便你遇到现象时直接对着查。现象可能原因处理方式转发后返回 403目标主机不在allowed_targets白名单把目标 IP 或域名加进白名单浏览器报跨域错误响应头缺少 CORS 头打开add_cors_headers或将源加入白名单请求超时后端响应慢或timeout太小调大 timeout确认后端可达后端收到请求但路由 404代理路由前缀和后端实际路径不匹配检查routes前缀是否覆盖请求路径登录后一直 401Cookie 未跨域携带设置credentials: include并调整 CORS 头响应体是乱码/压缩块后端强制 Gzip过滤 Accept-Encoding 或设置自动解压状态码变成 302 后的页面cURL 自动跟随重定向关闭FOLLOWLOCATION或改配置项日志里没有请求记录日志目录无写权限检查 proxy.log 所在目录权限并去掉调试启动命令报端口占用8080 被其他进程占用换一个端口再启动这张表是我实际踩坑的记录汇总大部分问题都是配置和转发语义理解上没对齐导致的。对照着排查基本几分钟就能定位。我自己现在把这份 proxy.php 固定放在~/tools/proxy.php命令行里配了一条 aliasalias dev-proxyphp -S 127.0.0.1:8080 ~/tools/proxy.php。需要联调时一行命令启动用完 Ctrl-C 关掉。它帮我解决过太多“前端配跨域、后端改接口、临时连同事服务”的琐碎问题。如果你也在做前后端分离开发或者经常要在一个入口里接好几个后端服务不妨把这份脚本留下来大概率某一天能派上用场。