前言

表单重复提交是老问题:用户手快点了两下提交、网络卡顿刷新重发,数据就插了两条。最常见的解法是提交完成后 redirect 跳转,但它解决不了 ajax 提交和移动端提交的场景(没有页面可跳)。通行的做法是用 token:django 等框架内置了这套机制,SpringMVC 需要自己搭,这篇把完整实现整理出来(思路参考了网上的经典方案,见文末参考)。

1、token 防重的逻辑

给需要防重的 URL 挂一个拦截器:

  1. 进入页面(GET 请求渲染表单)时,拦截器生成一个随机 UUID 放进 session,页面表单里带上这个 token
  2. 提交数据时,token 随表单一起提交,拦截器比对表单里的 token 和 session 里的 token:一致则放行并销毁 session 里的 token;再提交第二次,session 里已经没有 token 了,判定为重复提交,直接拦下

因为 token 用完即焚,同一个表单第二次提交必然对不上号,这就是防重的核心。

2、自定义 @Token 注解

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 防重复提交注解:
 * save=true   进入页面的方法上标记,生成 token 放入 session
 * remove=true 提交处理的方法上标记,校验并销毁 token
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Token {
    boolean save() default false;
    boolean remove() default false;
}

3、拦截器 TokenInterceptor

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import java.lang.reflect.Method;
import java.util.UUID;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

/**
 * token 拦截器:配合 @Token 注解实现防重复提交
 */
public class TokenInterceptor extends HandlerInterceptorAdapter {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            Method method = handlerMethod.getMethod();
            Token annotation = method.getAnnotation(Token.class);
            if (annotation != null) {
                boolean needSaveSession = annotation.save();
                if (needSaveSession) {
                    // 进入页面:生成 token 放入 session
                    request.getSession(false).setAttribute("token", UUID.randomUUID().toString());
                }
                boolean needRemoveSession = annotation.remove();
                if (needRemoveSession) {
                    // 提交数据:先校验,重复则拦截
                    if (isRepeatSubmit(request)) {
                        return false;
                    }
                    // 校验通过,销毁 session 里的 token
                    request.getSession(false).removeAttribute("token");
                }
            }
            return true;
        } else {
            return super.preHandle(request, response, handler);
        }
    }

    /**
     * 重复提交判断:session 无 token、请求无 token、两者不一致,都算重复
     */
    private boolean isRepeatSubmit(HttpServletRequest request) {
        String serverToken = (String) request.getSession(false).getAttribute("token");
        if (serverToken == null) {
            return true;
        }
        String clientToken = request.getParameter("token");
        if (clientToken == null) {
            return true;
        }
        if (!serverToken.equals(clientToken)) {
            return true;
        }
        return false;
    }
}

4、注册拦截器

spring-mvc 配置文件:

1
2
3
4
5
6
7
<mvc:interceptors>
    <!-- Token 拦截器,防止用户重复提交数据 -->
    <mvc:interceptor>
        <mvc:mapping path="/**"/>
        <bean class="com.example.web.spring.TokenInterceptor"/>
    </mvc:interceptor>
</mvc:interceptors>

5、使用方法

两个注解配合用:

1
2
3
4
5
6
7
8
9
/** 进入页面:save=true 生成 token */
@Token(save = true)
@RequestMapping(value = "/add", method = RequestMethod.GET)
public String add(Model model) { ... }

/** 提交数据:remove=true 校验并销毁 token */
@Token(remove = true)
@RequestMapping(value = "/save", method = RequestMethod.POST)
public String save(UserForm form) { ... }

页面表单里加上隐藏域:

1
<input type="hidden" name="token" value="${token}" />

搞定。之后用户再怎么疯狂点提交、刷新重发,第二次开始都会被拦截器拦下来。

6、几点补充

  1. token 存 session 有前提:应用是单机或有 session 共享(redis 集中存储),多机无共享 session 时 token 校验会随机会失败,这时把 token 挪到 redis(用户维度)是常见改造
  2. ajax 场景:拦截器 return false 时最好统一返回一个约定 JSON(而不是空白页),前端据此提示「请勿重复提交」
  3. 前端配合:提交时按钮置灰 + loading 态,能挡掉 90% 的手快,后端 token 兜底剩下的 10%
  4. 这套思路是框架无关的,django 的 CSRF 中间件、laravel 的 CSRF token 本质都是「页面发 token、提交验 token、用完即焚」

参考