在 Spring Boot 里做传统页面开发时,form 表单怎么发 PUT、DELETE 请求,是很多全栈开发者都会碰到的一道坎。你可能遇到过一个很诡异的现象:页面上明明写了<form method="delete">,打开浏览器开发者工具却发现发出去的是 POST,后端 @DeleteMapping 根本匹配不上,控制台飘一个 405。问题根源其实很直接:HTML 规范里 form 的 method 属性只支持 GET 和 POST,put 也好 delete 也好,对浏览器来说都是不认识的值,最终会被忽略甚至当成 GET 处理。这篇内容我会把这个问题讲透:为什么会有这个限制,Spring Boot 用什么机制把 POST 请求“翻译”成 PUT/DELETE,怎么配置、怎么写表单、怎么调试,最后把常见的坑整理成排查清单,可以直接对照使用。
1. 先搞清楚:为什么 form 表单发不了 PUT/DELETE
1.1 HTML 规范对 method 属性的约束
很多初学者第一反应是“浏览器不认识 PUT 和 DELETE”,其实不太准确。浏览器不是不认识 HTTP 方法,而是 HTML 规范在设计 form 时就把 method 的枚举值限定成了 get 和 post 两个。任何其他值都是非法枚举,浏览器遇到非法枚举会按默认情况处理,也就是 GET。
有个容易混淆的点:如果用 JavaScript 去改 form 的 method 属性,或者用 axios、fetch 调用接口,浏览器是完全可以发出 PUT/DELETE 请求的。因为 XHR 和 fetch API 不受 HTML form 的限制,它们是基于 HTTP 协议的客户端能力。真正被限制的只是<form>这个 HTML 元素的语义。
所以当页面上写<form method="delete">,不要惊讶它会失败,因为这样的写法从语法层面就不成立。如果服务端渲染过程中用了某些模板语法,比如 Thymeleaf 的th:method="delete",它之所以能工作,是因为 Thymeleaf 会把这个声明编译成“表单 method 保持 post,再塞一个隐藏的_method=delete参数”的处理,而不是真的让浏览器发出 DELETE 请求。
1.2 RESTful 风格让方法语义问题被放大
早期互联网应用大多是 GET 拉页面、POST 提交数据,表单天然够用。但后来大家开始设计 RESTful 接口,用 URI 表示资源,用 HTTP 方法表示动作:GET 查询、POST 新增、PUT 更新、DELETE 删除,甚至 PATCH 做部分更新。接口层这样做没问题,因为前端调用接口的客户端(axios、fetch、curl)都不受 form 限制。
难的是服务端渲染页面。运营后台、CMS 后台、老管理系统中,前端往往就是一堆 HTML 模板配表单,没有复杂的前端工程。这些场景里,我们希望页面里删除一条数据就发出一个 DELETE 请求,编辑保存就发出一个 PUT 请求,这样 Controller 层面的路由可以写得很统一:@GetMapping、@PutMapping、@DeleteMapping 各管各的。
但 HTML form 不给这个方法,导致最自然的做法落空了。于是大家想了很多替代方案,核心都是“曲线救国”,把真正的方法语义通过另一个参数传递到服务端,再由服务端还原。
1.3 几种常见绕过方案怎么选
实际项目里我见过这几类做法:
第一种,用 JavaScript 拦截 submit,改为 fetch/XHR 直接发出 PUT/DELETE。优点是 HTTP 协议层面就是真方法;缺点是很依赖 JavaScript,遇到禁用 JS 的浏览器或简单静态页面就失效,而且还要处理按钮、回车提交、表单校验等细节。
第二种,把表单方法写成 POST,但额外携带一个参数_method=delete,由服务端过滤器识别后把这次请求“伪装”成 DELETE。Spring MVC 的 HiddenHttpMethodFilter 干的就是这件事。优点是兼容所有浏览器,不需要改前端逻辑;缺点是严格来说浏览器发出的还是 POST,需要通过约定让服务端明白真实意图。
第三种,不用 RESTful 风格,继续 POST/user/delete这种 URL 表达动作。很多老项目是这么做的,也能工作,只是接口风格不统一。
对比下来,如果技术栈是 Spring Boot,且以服务端渲染模板为主,我通常推荐第二种,也就是 Spring 官方提供的 HiddenHttpMethodFilter。它不侵入业务代码,配置成本极低,表单只要多一个隐藏字段即可。下面把这个机制拆开看。
2. Spring Boot 的核心机制:HiddenHttpMethodFilter
2.1 它是怎么把 POST 翻译成 PUT/DELETE 的
Spring Web 里有一个专门处理这个问题的过滤器,类名叫 HiddenHttpMethodFilter,父类是 OncePerRequestFilter,所以一次请求最多执行一次。它的逻辑非常精简:
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String paramValue = request.getParameter(this.methodParam); // methodParam 默认是 _method if ("POST".equals(request.getMethod()) && StringUtils.hasLength(paramValue)) { String method = paramValue.toUpperCase(Locale.ENGLISH); if (ALLOWED_METHODS.contains(method)) { // ALLOWED_METHODS 为 PUT、DELETE、PATCH HttpMethodRequestWrapper wrapper = new HttpMethodRequestWrapper(request, method); filterChain.doFilter(wrapper, response); return; } } filterChain.doFilter(request, response); }关键点有三个。
第一,只有 POST 请求才会被检查。换句话说,当表单提交 method="post" 并带上_method=put时,这个过滤器才有机会生效。如果浏览器发的是 GET,它不会管。
第二,它通过request.getParameter("_method")获取隐藏字段的值。这意味着_method既可以放在表单 body 里,也可以放在 URL query string 里,Spring 的请求参数解析会把两者合并在一起考虑。
第三,如果值不是 PUT、DELETE、PATCH 中的某个,过滤器也不会做转换,请求会继续往下走,依然是 POST 语义。这样做是刻意保守的,避免把任何随手写的参数都当成方法覆盖指令。
转换的核心在于 HttpMethodRequestWrapper,它继承自 HttpServletRequestWrapper,只重写了一个方法:
private static class HttpMethodRequestWrapper extends HttpServletRequestWrapper { private final String method; public HttpMethodRequestWrapper(HttpServletRequest request, String method) { super(request); this.method = method; } @Override public String getMethod() { return this.method; } }一个 Servlet 请求对象里的getMethod()返回值被改写了,后续 Spring MVC 在处理路由映射时调用request.getMethod(),拿到的自然就是 PUT 或 DELETE,于是@PutMapping能匹配这个方法。服务器容器层面则完全不知道这个改动,它只知道自己处理了一个 POST。
2.2 Spring Boot 中怎么开启这个过滤器
这是最容易踩坑的地方。Spring Boot 不同版本对这个过滤器的默认配置不一样。印象比较深的一次是某个项目从早期版本升级到 2.x,升级后所有表单 delete/put 全部失效,原因就是新版默认没有注册 HiddenHttpMethodFilter。
为了不让版本问题干扰排错,最稳妥的做法是在配置文件里显式打开:
spring.mvc.hiddenmethod.filter.enabled=true在 Spring Boot 2.x 和 3.x 里,这个配置项都是有效的。显式开启之后,Spring Boot 会自动注册一个 OrderedHiddenHttpMethodFilter,它的优先级设计得比较靠前,能让后续的 Spring Security 过滤器链和 DispatcherServlet 都拿到转换后的方法。
如果项目自定义了 HiddenHttpMethodFilter 类型的 Bean,那么配置项是否开启已经不重要了,因为你手动注册的 Bean 会替代自动配置。这个技巧后面讲自定义过滤器时还会提到。
2.3 表单端和 URL 端到底怎么写
先看最基本的 HTML 写法。编辑用户页面里,要提交一个 PUT 请求:
<form action="/users/1" method="post"> <input type="hidden" name="_method" value="put" /> <input type="text" name="name" value="张三" /> <input type="email" name="email" value="zhangsan@example.com" /> <button type="submit">保存修改</button> </form>删除操作的表单更常见,因为它往往只含一个按钮:
<form action="/users/1" method="post" style="display: inline"> <input type="hidden" name="_method" value="delete" /> <button type="submit">删除</button> </form>_method的值大小写随意,filter 内部会转成大写再比对,所以 value="put" 和 value="PUT" 都行。
如果用的是 Thymeleaf,模板引擎提供了一种更简练的写法。th:method="put"会自动生成一个名为_method的隐藏字段,同时把表单的 method 属性固定为 post:
<form th:action="@{/users/{id}(id=${user.id})}" th:method="put"> ... </form>但我的建议是:除非整个团队都很清楚 Thymeleaf 这个机制,否则在页面里显式写<input type="hidden" name="_method" value="put" />虽然多一行,排查问题时却少一层神秘感。尤其是从别的模板引擎比如 FreeMarker 或 JSP 切过来的项目,显式写法思路完全一致,一眼就能看到完整的请求参数。
3. 完整实操:一个最小可运行的用户 CRUD
3.1 工程依赖和基础配置
这里我不会引入数据库,用一个内存 Map 当存储,这样照着做起来不用搭库,重点放在方法转换这条主线上。
pom.xml 核心依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>application.properties:
spring.mvc.hiddenmethod.filter.enabled=true spring.thymeleaf.cache=false server.port=8080spring.thymeleaf.cache=false是开发期建议,避免改模板后需要频繁重启才能看到变化。生产环境记得关掉或者交给运维配置。
3.2 实体类和控制器
User 实体:
public class User { private Long id; private String name; private String email; public User() {} public User(Long id, String name, String email) { this.id = id; this.name = name; this.email = email; } // getter / setter 省略,务必全部生成 }Controller 里,我特意把方法映射写成 RESTful 风格:
@Controller @RequestMapping("/users") public class UserController { private final Map<Long, User> store = new ConcurrentHashMap<>(); private final AtomicLong idGenerator = new AtomicLong(1); @GetMapping public String list(Model model) { model.addAttribute("users", store.values()); return "list"; } @GetMapping("/new") public String createForm(Model model) { model.addAttribute("user", new User()); return "form"; } @PostMapping public String create(User user) { long id = idGenerator.getAndIncrement(); user.setId(id); store.put(id, user); return "redirect:/users"; } @GetMapping("/{id}/edit") public String editForm(@PathVariable Long id, Model model) { model.addAttribute("user", store.get(id)); return "form"; } @PutMapping("/{id}") public String update(@PathVariable Long id, User user) { user.setId(id); store.put(id, user); return "redirect:/users"; } @DeleteMapping("/{id}") public String delete(@PathVariable Long id) { store.remove(id); return "redirect:/users"; } }这个 Controller 里,create 和 edit 共用了同一个模板文件,通过判断传入的 user 是否为空来决定显示新建还是编辑状态。store 用 ConcurrentHashMap 是为了并发语义靠谱一点,演示项目无所谓,但别在生产环境用这种内存存储当持久层。
3.3 模板页面写法
list.html 负责展示列表和每行的操作按钮:
<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>用户列表</title> </head> <body> <h1>用户列表</h1> <a th:href="@{/users/new}">新增用户</a> <table border="1"> <tr> <th>ID</th> <th>姓名</th> <th>邮箱</th> <th>操作</th> </tr> <tr th:each="user : ${users}"> <td th:text="${user.id}">1</td> <td th:text="${user.name}">张三</td> <td th:text="${user.email}">a@example.com</td> <td> <a th:href="@{/users/{id}/edit(id=${user.id})}">编辑</a> <form th:action="@{/users/{id}(id=${user.id})}" method="post" style="display:inline"> <input type="hidden" name="_method" value="delete"/> <button type="submit">删除</button> </form> </td> </tr> </table> </body> </html>form.html 同时承接新增和编辑:
<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>用户表单</title> </head> <body> <h1 th:text="${user.id != null} ? '编辑用户' : '新增用户'">表单</h1> <form th:action="${user.id != null} ? @{/users/{id}(id=${user.id})} : @{/users}" th:method="${user.id != null} ? 'put' : 'post'"> <input type="hidden" name="_method" th:if="${user.id != null}" value="put"/> <div> <label>姓名</label> <input type="text" name="name" th:value="${user.name}"/> </div> <div> <label>邮箱</label> <input type="email" name="email" th:value="${user.email}"/> </div> <button type="submit">保存</button> </form> </body> </html>注意,这里把th:method和_method隐藏字段同时写了出来。严格来说 Thymeleaf 的th:method="put"会自动补一个隐藏字段,所以隐藏字段是重复的。我在生产环境一般只保留th:method,但为了让读者不用关心模板的魔法行为,示例里显式写了一遍。实际写代码时建议删掉重复隐藏字段,只保留th:method或者只保留显式字段,二选一。
3.4 验证:浏览器、命令和日志
启动项目后,访问http://localhost:8080/users,可以执行完整的新增、编辑、删除流程。打开浏览器开发者工具的 Network 面板,你会发现所有“编辑保存”“删除”请求的 HTTP Method 都显示为 POST,但在 Payload 表单数据里有_method=put或者_method=delete。这点非常重要,能帮助你确认真正发送的流量长什么样,避免和服务端认知不一致。
如果想用命令行快速模拟,可以用 curl:
curl -X POST http://localhost:8080/users/1 \ -d "_method=put&name=李四&email=lisi@example.com"curl -X POST http://localhost:8080/users/1 \ -d "_method=delete"注意这里必须用-X POST,而不是-X PUT。因为服务端过滤器只认 POST 请求里的_method参数。如果你真的用-X PUT发请求,请求体里的_method=put依然在,可是过滤器根本不检查 PUT 请求,Spring MVC 能不能把参数解析出来取决于 body 的 content type 和 form content filter。不建议把这种用法当常规方案。
如果 Controller 的方法里打了日志,你会看到@PutMapping和@DeleteMapping确实被触发了。这足以证明“翻译”完成。
4. 各种坑:从 405 到参数绑定失败
4.1 405 错误排查清单
表单提交后最常见的错误就是 405 Method Not Allowed。遇到这个状态码,我通常按顺序查三件事。
第一种情况,HiddenHttpMethodFilter 没生效。配置文件里忘了加spring.mvc.hiddenmethod.filter.enabled=true,或者项目里的自定义过滤器覆盖掉了自动配置。排查方式很简单,在表单提交后打印一下后端 Controller 收到的请求方法,或者临时在过滤器链里打点。如果 Controller 根本没进,那就说明请求方法不是预期值,第一个嫌疑就是 filter 没挂上。
第二种情况,URL 路径没匹配上。比如表单action="/users/edit",后端却写的是@PutMapping("/users/{id}"),那么请求进入 Spring MVC 后找不到对应的 @PutMapping,于是 405。这种问题跟 filter 无关,是路由设计不一致。
第三种情况,_method参数名写错,或者压根没传。有人会把隐藏字段 name 写成method而不是_method,结果过滤器自然没反应,请求保持 POST 语义,同样会 405,或者被某个 @PostMapping 意外接收。
405 的排查表整理如下:
| 现象 | 可能原因 | 解决 |
|---|---|---|
| 提交后 Controller 未进入,响应 405 | filter 未开启 | 检查spring.mvc.hiddenmethod.filter.enabled=true |
| 请求是 POST,但找不到处理器 | URL 与 @PutMapping 路径不一致 | 统一 action 与映射路径 |
| 过滤器不转换 | _method 参数缺失或 name 错误 | 检查隐藏字段 name 是否为_method |
| 自定义过滤器优先级问题 | 多个 filter 导致包装丢失 | 确认自定义过滤器在正确的位置 |
4.2 参数绑定方式:为什么 @RequestBody 经常拿不到数据
这是新手最容易误解的地方。看到@PutMapping,下意识就在方法参数上写@RequestBody User user,然后发现请求参数始终是 null。
问题的本质是:这个请求从 HTTP 协议上看仍然是 POST,Content-Type 默认是application/x-www-form-urlencoded,请求体是普通表单字符串。Spring MVC 的@RequestBody会把请求体交给 HttpMessageConverter 去反序列化,通常按 JSON 或 XML 来处理。面对name=李四&email=lisi@example.com这种字符串,JSON 解析器根本没法工作,轻则抛异常,重则整个请求 400。
在表单方法转换这个模式里,正确的接收姿势是让 Spring MVC 做对象绑定,也就是不写@RequestBody:
@PutMapping("/{id}") public String update(@PathVariable Long id, User user) { ... }Spring MVC 会拿请求参数去填充 User 对象的 setter。用@ModelAttribute User user也是一样,它是显式声明。这里隐含一个前提:User 类必须有默认构造函数和对应的 setter。
如果只关心一两个字段,也可以直接用@RequestParam:
@PutMapping("/{id}") public String update(@PathVariable Long id, @RequestParam String name, @RequestParam String email) { ... }总之,“表单 POST + _method 转换”这个组合,本质上还是表单参数模型,不是 JSON 模型。想接收 JSON,就直接让前端用 fetch 发真正的 PUT,不要走 form。
4.3 中文乱码和过滤器顺序
早期项目很容易出现中文乱码,现象是表单提交后保存到数据库或者回显到页面时,中文变成问号。根本原因在请求编码没有设置对。
Spring Boot 里一般通过 CharacterEncodingFilter 强制请求和响应使用 UTF-8,其顺序通常很高,在 HiddenHttpMethodFilter 之前就已经工作,所以只要按默认配置,POST 表单的中文应该没问题。但如果自己注册了编码过滤器,要记得把顺序放在所有业务过滤器之前,因为只要后续还有读参数、写响应的环节,编码不对,后面再怎么补救都白搭。
另外一个技巧是设置 Tomcat 的 URI 编码,防止 URL query string 中文乱码。配置文件加:
server.tomcat.uri-encoding=UTF-8现在默认已经是 UTF-8,这行更多是为老项目定心用。
4.4 和 Spring Security CSRF 的配合
如果项目启用了 Spring Security 的 CSRF 防护,表单方法转换要特别注意。CSRF Token 校验默认对所有非安全方法生效,而经过 HiddenHttpMethodFilter 转换后的 PUT/DELETE 请求同样属于需要校验的方法。
Thymeleaf 配合 Spring Security 时,模板里可以直接用th:action配合隐藏的_csrf字段,或者手动加:
<input type="hidden" name="_csrf" th:value="${_csrf.token}" />必须保证这个字段和_method同时存在于同一个表单里,否则 Security 会拦截请求。这不算 bug,是安全设计。
如果按照默认顺序,HiddenHttpMethodFilter 会先于 Spring Security 的 filter chain 执行,所以 Security 看到的请求方法已经是 PUT/DELETE,它照样要求 CSRF token,行为是一致的。反过来也没问题,Security 看到的是 POST,POST 也在校验范围内。两种顺序下,你只需要提供 token。
有一个容易忽略的点:如果写了一个自定义 filter,在_method转换之后又做了重定向或者包装了 request,可能导致getParameter第二次拿不到 token,这种坑比较隐蔽。排查 CSRF 问题时,优先看表单实际提交的参数里有没有_csrf,再考虑过滤器顺序。
4.5 模板选择:Thymeleaf 自动字段和原生 HTML 的差异
Thymeleaf 的处理很贴心,th:method="put"会自动生成隐藏的_method字段,这也是很多教程里直接写完就能跑的原因。但不熟悉这个机制的人,看到页面渲染成<form method="post">会一头雾水,因为模板里写的明明是 put。所以我在团队内部约定:除非有特殊原因,表单里一律显式写隐藏字段,模板生成的结果就是最终的 HTTP 请求内容,避免“魔法”。
用原生 HTML 页面时,form 的 action 必须指向后端接口,隐藏字段直接写在 HTML 文件里就行:
<form action="http://localhost:8080/users/1" method="post"> <input type="hidden" name="_method" value="put"/> <input type="text" name="name"/> <button>保存</button> </form>这种写法不依赖任何模板引擎,只要后端 filter 开启,一样生效。用 JSP、FreeMarker 也同理,核心就是隐藏字段加 POST 表单。
5. 方案背后的权衡和进一步扩展
5.1 为什么它适合传统页面项目
HiddenHttpMethodFilter 这套方法转换机制,在服务端渲染的老项目中几乎是成本最低的解法。优点很明确:不要求前端工程化,不依赖 JavaScript,表单怎么写、传什么参数,服务端一目了然;路由可以完全按 RESTful 语义组织,Controller 代码更统一。
缺点也不能忽略。浏览器真正发出的请求始终是 POST,这在严格的 API 网关日志或者服务端审计里,记录的 HTTP Method 不是业务语义上的方法。如果团队要求严格的 HTTP 语义,表单转换只能算一种“兼容层”,不能替代协议层的方法表达。
另外,跨域、网关、防火墙等中间件如果针对 PUT/DELETE 有特殊策略,它们看到的是 POST,可能不会执行那些策略。反过来,如果因为中间件把 PUT 请求拦截了,而这个页面实际是 POST,反而能通过。这个特性算优点还是缺点,看具体场景。
5.2 如果项目想自定义参数名或逻辑怎么做
默认的_method参数名是框架规定的。如果因为某些原因想换一个名字,或者想支持自定义方法,可以通过实现 Filter 来覆盖默认逻辑。甚至不用 Spring 的过滤器,自己在 Controller 里读参数也能做,但那就把通用逻辑散落到各个方法里了,不推荐。
给一个最小自定义过滤器示例:
@Component public class HttpMethodOverrideFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { if ("POST".equalsIgnoreCase(request.getMethod())) { String override = request.getParameter("_method"); if (override != null && !override.isBlank()) { String method = override.toUpperCase(Locale.ENGLISH); if (method.equals("PUT") || method.equals("DELETE") || method.equals("PATCH")) { HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(request) { @Override public String getMethod() { return method; } }; chain.doFilter(wrapper, response); return; } } } chain.doFilter(request, response); } }这个代码只是为了展示原理。实际项目里我通常不推荐自己实现,直接用 Spring 官方的 HiddenHttpMethodFilter,维护成本低,行为也经过大量测试。只有一种情况例外:你需要同时支持 GET 请求也带_method参数做转换,这时官方 filter 不会处理,才需要自定义。
5.3 API 层和现代前端建议走真正的 HTTP 方法
如果界面已经不是传统服务端渲染页面,而是 Vue、React 这类工程,直接用 fetch 或者 axios 发真正的 PUT/DELETE 请求更干净。前端框架完全有能力发出任意 HTTP 方法,不需要_method参数和隐藏字段。
那么什么时候还会用到本文这套?我见过最多的是这些场景:老系统改造,前后端还没分离;运维脚本需要临时模拟删除动作;以及一些低代码平台,仍然靠 form 表单提交数据。在这些场景里,表单方法转换是兼容旧世界的窗口。
如果用 fetch 发真正的 PUT 请求,又要提交application/x-www-form-urlencoded格式,后端接收参数时要考虑 FormContentFilter。Spring Framework 5.1 之后提供的 FormContentFilter,会把 PUT/PATCH 请求体里的表单内容解析进参数列表,所以@RequestParam也能用上。但我的建议是,新接口直接用 JSON,别再用 form 格式混搭,避免踩消息转换的坑。
5.4 一个可以立刻落地的公共模板片段
如果已经决定在项目里统一用表单方法转换,我建议把隐藏字段封装成一个模板片段,不要每个页面手写。
Thymeleaf 里可以这样定义 fragments/methods.html:
<th:block th:fragment="method(methodName)"> <input type="hidden" name="_method" th:value="${methodName}" /> </th:block>其他页面引入:
<form th:action="@{/users/{id}(id=${user.id})}" method="post"> <th:block th:replace="~{fragments/methods :: method('put')}"></th:block> ... </form>这样做的好处是,以后要调整参数名或者统一加逻辑,只需要改一个地方。团队新人也少踩坑,因为看到的是明确的隐藏字段,而不是模板引擎的魔法。
在实际项目里,我还会刻意把删除按钮放进内联 form,并加一个二次确认事件。因为删除是危险操作,表单里只有_method=delete一个参数,加确认后误触概率会低很多。细节虽小,但运营后台每天被删的数据都是大事。
最后分享一个排查工具的小技巧:遇到表单方法转换相关的疑难杂症,别光看后端日志,先在浏览器开发者工具里看清请求方法和请求参数,再对比服务端实际进入的方法。很多时候,问题不在服务端逻辑,而是前端表单没有生成预期的隐藏字段,或者 URL 不匹配。把这两层对齐,问题基本就解决了。这个模式,在维护过几个老管理后台项目之后,我越发觉得值得坚持。