CRMEB 故障排查笔记:安全网关只放行 GET/POST 怎么办

markdown 复制代码
# 安全网关拦掉 PUT / DELETE 之后,我在入口文件里做了一次「HTTP 动词还原」

> 环境:CRMEB-BZ v4.4.1(ThinkPHP 6 多应用 + Vue 2 / iView 前后端分离)
> 症状:后台删除轮播图、删除 DIY 页面、修改栏目,全部失败。

## 一、现象:一个删不掉的轮播图

点删除,接口没反应,或者报 404 / 连接被重置。看 Network:

DELETE https://example.com/adminapi/diy/del/12

→ 请求压根没到 PHP,被中间的安全网关掐了

复制代码
排查这类问题,**第一步永远是绕开所有中间层,直连 PHP 打一次**:

```powershell
# ① 直连后端,验证是不是网关问题
curl.exe -i -X DELETE "https://域名/adminapi/diy/del/12" -H "Authori-zation: Bearer <token>"

# ② 顺便验证 PUT 是不是同样被拦
curl.exe -i -X PUT "https://域名/adminapi/diy/set_status/12" -H "Authori-zation: Bearer <token>"

判读方式很干脆:

现象 结论
405 / HTML 错误页 / 连接被重置 网关(或 Nginx / WAF / CDN)拦的,代码一行都不用改
返回 CRMEB 的 JSON(哪怕是 {"status":400}) 请求已进框架,是路由或参数问题,与方法无关

我这边是第二种情况之外的前一种:安全网关只放行 GET / POST。

二、能放行就放行,别急着写代码

PUT / DELETE 被拦,常见来源:

  • Nginx 站点配置里的 limit_except
  • Apache 的 <LimitExcept GET POST>
  • IIS:WebDAV 模块劫持(<remove name="WebDAVModule" />)
  • 宝塔的「免费防火墙 / WAF」规则
  • CDN 的防护策略,默认就砍非 GET/POST

能放开就放开,这是唯一不用重打包前端的解。 我这里是网关策略不可动,才走下面的代码方案。

三、影响面比「删轮播图」大得多

先量化一下,别以为只是一个小功能坏了:

php 复制代码
// app/adminapi/route/*.php
Route::resource('slide', 'v1.setting.Slide');
// ↑ 一组 resource 自动生成 save / read / update / delete
// 其中 PUT 和 DELETE 各占一条,全站共 24 组

再加上显式写的 Route::delete(...) / Route::put(...),受影响的接口上百个。而且不只影响删除------所有「编辑保存」「状态开关」「排序更新」这类走 PUT 的,一起坏。

前端对应的调用点:

js 复制代码
// view/admin/src/api/product.js
return request({ url: `product/${id}`, method: 'delete' })

view/admin/src/api/ 下 25+ 个文件、上百处 method: 'put' | 'delete'。逐个改成 method: 'post' 显然不现实。

四、方案:前端降级 + 后端还原,必须成对

这是配对方案,单独改任何一边都是无效的,原因很具体:

  • 只改后端 :前端继续发原生 DELETE,请求在网关就被掐了,后端那段还原代码永远不会执行,等于白写。
  • 只改前端 :请求变成 POST,网关放行,但 ThinkPHP 拿到的动词是 POST,而路由定义是 Route::delete(...) → 匹配不上 → 落进后台路由表的 miss,返回一个裸 404(没有 JSON body,前端连错误提示都弹不出来)。

完整链路:
#mermaid-svg-zsz4Jcqh6HtsLdKV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zsz4Jcqh6HtsLdKV .error-icon{fill:#552222;}#mermaid-svg-zsz4Jcqh6HtsLdKV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zsz4Jcqh6HtsLdKV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .marker.cross{stroke:#333333;}#mermaid-svg-zsz4Jcqh6HtsLdKV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zsz4Jcqh6HtsLdKV p{margin:0;}#mermaid-svg-zsz4Jcqh6HtsLdKV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster-label text{fill:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster-label span{color:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster-label span p{background-color:transparent;}#mermaid-svg-zsz4Jcqh6HtsLdKV .label text,#mermaid-svg-zsz4Jcqh6HtsLdKV span{fill:#333;color:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .node rect,#mermaid-svg-zsz4Jcqh6HtsLdKV .node circle,#mermaid-svg-zsz4Jcqh6HtsLdKV .node ellipse,#mermaid-svg-zsz4Jcqh6HtsLdKV .node polygon,#mermaid-svg-zsz4Jcqh6HtsLdKV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .rough-node .label text,#mermaid-svg-zsz4Jcqh6HtsLdKV .node .label text,#mermaid-svg-zsz4Jcqh6HtsLdKV .image-shape .label,#mermaid-svg-zsz4Jcqh6HtsLdKV .icon-shape .label{text-anchor:middle;}#mermaid-svg-zsz4Jcqh6HtsLdKV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .rough-node .label,#mermaid-svg-zsz4Jcqh6HtsLdKV .node .label,#mermaid-svg-zsz4Jcqh6HtsLdKV .image-shape .label,#mermaid-svg-zsz4Jcqh6HtsLdKV .icon-shape .label{text-align:center;}#mermaid-svg-zsz4Jcqh6HtsLdKV .node.clickable{cursor:pointer;}#mermaid-svg-zsz4Jcqh6HtsLdKV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .arrowheadPath{fill:#333333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zsz4Jcqh6HtsLdKV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zsz4Jcqh6HtsLdKV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster text{fill:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster span{color:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zsz4Jcqh6HtsLdKV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV rect.text{fill:none;stroke-width:0;}#mermaid-svg-zsz4Jcqh6HtsLdKV .icon-shape,#mermaid-svg-zsz4Jcqh6HtsLdKV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zsz4Jcqh6HtsLdKV .icon-shape p,#mermaid-svg-zsz4Jcqh6HtsLdKV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .icon-shape .label rect,#mermaid-svg-zsz4Jcqh6HtsLdKV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zsz4Jcqh6HtsLdKV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zsz4Jcqh6HtsLdKV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zsz4Jcqh6HtsLdKV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Vue 组件

request({method:'delete'})
axios 请求拦截器

降级为 POST
网关

POST 放行
public/index.php

还原 REQUEST_METHOD=DELETE
TP6 Request

读到 DELETE
Route::delete 命中

控制器执行

4.1 前端:只改一个文件

先确认哪个封装在生效------CRMEB 后台里有个死文件:

js 复制代码
// view/admin/src/libs/axios.js
this.baseUrl = 'http://admin.crmeb.net/adminapi'   // 硬编码官方演示域名

它没有任何 api/*.js 引用。真正生效的是 view/admin/src/libs/request.js(全部 25+ 个 api 文件都是 import request from '@/libs/request')。

改错文件是这类方案最常见的翻车点:代码写了,重新打包了,功能依旧坏,因为你改的那份根本没被 import。

在 request.js 的请求拦截器里、return config 之前插入:

js 复制代码
// view/admin/src/libs/request.js
// 安全网关只放行 GET/POST,PUT/DELETE/PATCH 降级为 POST,真实方法用 _method 传给后端还原
const realMethod = (config.method || '').toLowerCase();
if (realMethod === 'put' || realMethod === 'delete' || realMethod === 'patch') {
    config.params = Object.assign({}, config.params, { _method: realMethod.toUpperCase() });
    config.method = 'post';
}

return config

为什么 _method 放 query 而不是 body? 这是本方案第一个关键点:

当 Content-Type: application/json 时,PHP 不会 把请求体解析进 $_POST。

PUT 请求往往带 JSON 数据体,_method 塞进 body 里,后端从 $_POST['_method'] 取到的永远是 null。而放 query 对 JSON 体和 multipart/form-data 上传都一致生效。

顺带,Object.assign({}, config.params, ...) 而不是直接 config.params._method = ...,是为了不修改调用方传入的对象------避免模块级的 params 常量被污染。

4.2 后端:入口文件还原动词

放在 public/index.php,必须在 require autoload 和 new App() 之前。这是第二个关键点:

TP6 的 Request 是懒实例化 的,在 $http->run() 时才从 $_GET / $_POST / $_SERVER 超全局数组取值。

所以在这个位置改超全局数组是有效的------框架后面读到的就是你改写后的值。这等于借用了框架的初始化时序,而不是去 hook 框架内部。

php 复制代码
<?php
// public/index.php
namespace think;

define('DS', DIRECTORY_SEPARATOR);

// PUT/DELETE 传输层降级还原:安全网关只放行 GET/POST 时,
// 前端把 PUT/DELETE 发成 POST + ?_method=XXX,这里在框架实例化
// Request 之前还原真实动词,使路由定义、鉴权、日志零改动继续工作。
$crmebOverride = strtoupper((string)($_GET['_method'] ?? $_POST['_method'] ?? ''));
if (in_array($crmebOverride, ['PUT', 'PATCH', 'DELETE'], true)) {
    $crmebPath = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
    // 兼容 /index.php/adminapi/... 形式的伪静态
    if (strpos((string)$crmebPath, '/index.php') === 0) {
        $crmebPath = substr($crmebPath, 10);
    }
    if (empty($crmebPath) || $crmebPath === '/' || $crmebPath === '/index.php') {
        $crmebPath = (string)($_GET['s'] ?? $_SERVER['PATH_INFO'] ?? '');
    }
    $crmebApp = strtolower(explode('/', ltrim((string)$crmebPath, '/'))[0]);
    if ($crmebApp === 'adminapi' || $crmebApp === 'kefuapi') {
        $_SERVER['REQUEST_METHOD'] = $crmebOverride;
        error_log('[METHOD_OVR] ' . $crmebOverride . ' path=' . $crmebPath);
    }
}
// 无论是否命中作用域都摘掉,防止 _method 污染控制器全量取参
unset($_GET['_method'], $_POST['_method'], $_REQUEST['_method']);

require __DIR__ . '/../vendor/autoload.php';

$http = (new App())->http;
$response = $http->run();
$response->send();
$http->end($response);

四道保险,每一道都对应一个具体的坑:

① 白名单校验

in_array(..., ['PUT','PATCH','DELETE'], true) ------ 严格比较,_method=GET、_method=config 这类乱七八糟的值一律不认。传输层还原只做动词映射,不做参数注入。

② 作用域限定到后台应用

只对 /adminapi、/kefuapi 生效。这条不是洁癖,是必要的 :/api 下有微信支付通知和公众号消息推送回调,它们本身是 POST,一旦哪天有人在回调 URL 上挂了 _method,你会得到「支付通知变成一次 PUT 请求 → 路由匹配不上 → 掉单」这种噩梦级故障。

③ unset 无条件执行

_method 现在是个真实存在的 URL 参数。ThinkPHP 的 param() 是 GET + POST 合并,它会作为普通业务参数进入控制器:

  • 用 getMore() / postMore([...]) 显式列字段的地方(CRMEB 绝大多数)→ 无影响
  • 用 $request->post() / param() 全量取再入库的地方(系统配置保存、DIY 页面数据、组合数据)→ 可能报 fields not exists:[_method],或者写进 JSON 字段变成脏数据

最阴的地方在于:这个问题只在一部分接口上出现 。你测删除功能全绿,过两天改系统配置保存炸了,根本联想不到入口文件。所以 unset 放在 if 外面。

④ 路径取值用 REQUEST_URI 而不是 PATH_INFO

这是我第一版踩的坑,也是本篇最值得记的一条:

php 复制代码
// 反例
$crmebPath = $_SERVER['PATH_INFO'] ?? (string)($_GET['s'] ?? '');

?? 只在 key 不存在 时才兜底。而 Nginx + PHP-FPM 在没配 fastcgi_split_path_info 的情况下,PATH_INFO 往往是存在但值为空字符串 。空字符串是 isset 的,于是:

复制代码
$crmebPath = ''  →  $crmebApp = ''  →  不等于 'adminapi'  →  还原不执行

失效表现极具迷惑性:前端已经发成 POST,网关也放行了,请求进了框架,但因为动词还是 POST,Route::delete() 匹配不上,落进后台路由表的 miss 返回裸 404 ------ 你百分之百会以为是自己路由写错了,绝不会怀疑到入口文件这一行三元上。

parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) 才是可靠的,Nginx 重写不会改变 REQUEST_URI,它始终是浏览器发出的原始路径。

``

相关推荐
软件技术NINI34 分钟前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设
涛涛ing40 分钟前
我现在最怕的不是 AI 写不出代码,是它写得太快,快到我不好意思说“等一下”
前端
AllData公司负责人1 小时前
【产品便捷使用一】AIIData 数据中台:企业基础版核心能力支持,一站式全链路数据处理实操指南
大数据·前端·数据治理·数据可视化·数据开发·数据服务·数据接入
vipxieliang1 小时前
JavaScript 模板字符串与标签模板:不只是拼接
前端·javascript
恋猫de小郭1 小时前
Flutter 开始支持 Jetpack Compose Platform View
android·前端·flutter
IT_陈寒1 小时前
React实战技巧总结
前端·人工智能·后端
陈随易9 小时前
Postman 被曝自动往 Claude Code 和 Codex 装插件
前端·后端·程序员
Dream-Y.ocean11 小时前
鸿蒙PC平台宅匠 3D 室内设计适配实战:基于 Electron 壳方案的 Three.js 双渲染引擎与参数化户型编辑器开发
javascript·3d·harmonyos
Csvn12 小时前
性能监控
前端